Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0759d5dcff | ||
|
|
325b702934 | ||
|
|
4c7193e1ea | ||
|
|
e6ac0734de | ||
|
|
e8d93b5bb6 | ||
|
|
cd27163660 |
+19
-11
@@ -194,7 +194,9 @@ records are listed separately and do not introduce another feature status.
|
||||
reopen the upgraded database. The schema-35 repair released in 0.13.2 also handles
|
||||
repeated tool blocks written after upgrading to 0.13.1, without deleting events.
|
||||
Terminal tasks release their write caches; duplicate remote replay does not
|
||||
rebuild a released cache. See
|
||||
rebuild a released cache. Structure-only upgrades do not reconvert history or
|
||||
reclaim ordinary free pages; the startup page distinguishes structure updates,
|
||||
legacy conversion, and actual space reclamation. See
|
||||
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
|
||||
|
||||
- [x] **Direct model Runtime**: Supports question answering, knowledge
|
||||
@@ -322,7 +324,7 @@ records are listed separately and do not introduce another feature status.
|
||||
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.13.4`, while the current Desktop release candidate is `0.13.14`; formal
|
||||
`0.13.4`, while the current Desktop release candidate is `0.13.16`; 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
|
||||
@@ -757,9 +759,11 @@ records are listed separately and do not introduce another feature status.
|
||||
Automatic stages process only new, changed, or unprocessed source portions;
|
||||
no-change checks skip model calls. Manual review pages through the selected history
|
||||
and saves batches with their facts and sources. Runs can pause and resume from
|
||||
saved progress; settings expose concurrency, timeouts, batch sizes, execution
|
||||
budgets, and response capacity. Activity shows execution stages and saved batch
|
||||
details. Work review and activity use the available panel width.
|
||||
saved progress, without fixed-duration automatic pauses; settings expose
|
||||
concurrency, per-request timeouts, batch sizes, and response capacity.
|
||||
Activity emphasizes connected stages and saved-batch coverage, with secondary
|
||||
statistics in expandable details. Work review uses a vertical full-width
|
||||
flow with a separate run status and correctly placed empty state.
|
||||
Calls remain read-only without tools and may incur model charges. See the
|
||||
[implementation and evidence](./docs/features/conversation-supervision/progress.md).
|
||||
- [ ] **Further supervision capabilities** (planned): Full event-by-event replay,
|
||||
@@ -851,13 +855,16 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- Current source candidates are Desktop `0.13.14` and Agent `0.13.4`, with
|
||||
- The current Desktop candidate is `0.13.16`; Agent `0.13.4` is published, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.13.14` improves paged Supervisor reviews and resume behavior,
|
||||
- Desktop `0.13.15` carries forward the unpublished `0.13.14` changes to paged Supervisor reviews and resume behavior,
|
||||
native ripgrep searches, to-do layouts, and explicit remote follow-up after an
|
||||
uncertain result. Database schema 47 requires a complete pre-upgrade backup
|
||||
for rollback to older clients.
|
||||
- Desktop `0.13.16` separates structure upgrades from legacy history reclamation,
|
||||
removes fixed-duration review pauses, and clarifies Supervisor layouts.
|
||||
It adds no schema change over `0.13.15` and requires no Agent update.
|
||||
- Agent `0.13.3` reclaims idle Runtimes and retires displaced Agents after existing
|
||||
work drains. Its packages require Desktop `0.13.12`;
|
||||
update Desktop first, then the Host environment. Node remains `24.19.0`.
|
||||
@@ -865,13 +872,14 @@ records are listed separately and do not introduce another feature status.
|
||||
preserves completed results when idle Runtime cleanup fails, and removes verified
|
||||
inactive Agent payloads and owned orphan processes after environment updates.
|
||||
History and active or uncertain installations are retained. Its independent
|
||||
packages require Desktop `0.13.14`; upgrade Desktop first, then update the Host.
|
||||
- Release preparation requires the full local test suite, notes verification,
|
||||
packages require Desktop `0.13.14` or later. Since `0.13.14` did not publish,
|
||||
upgrade to Desktop `0.13.15` first, then update the Host.
|
||||
- Release preparation requires the full test suite, notes verification,
|
||||
typecheck, and lint. Candidate main-branch CI, its production build, native
|
||||
packaging, and publication verification remain separate acceptance steps.
|
||||
No local production build, packaging, or LoongArch preview is requested.
|
||||
Current validation status is recorded in the
|
||||
[release preparation record](./docs/development/release-preparation-0.13.14.md).
|
||||
[release preparation record](./docs/development/release-preparation-0.13.16.md).
|
||||
- Desktop `0.13.12` and Agent `0.13.3` have completed their native release jobs;
|
||||
this does not replace development-time real-Host scenario coverage.
|
||||
- Validation records remain separate from implementation status. Current-source
|
||||
@@ -921,7 +929,7 @@ records are listed separately and do not introduce another feature status.
|
||||
Windows desktop-to-Linux checks cover installation, checklist updates, long lists,
|
||||
cancellation and restart recovery. Execute session MCP delivery passed real
|
||||
text-model Host checks with a substituted image service, not real image generation.
|
||||
Candidate Agent `0.13.4` requires Desktop `0.13.14`; publication and native
|
||||
Published Agent `0.13.4` requires Desktop `0.13.14` or later; publication and native
|
||||
platform acceptance remain separate from Linux development evidence. See the
|
||||
[validation record](./docs/features/remote-host/runtime-checklist-validation.md).
|
||||
|
||||
|
||||
+9
-8
@@ -41,7 +41,7 @@
|
||||
|
||||
### Agent Runtime 与模型连接
|
||||
|
||||
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。已随 0.13.2 发布的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件;终态及时释放写入缓存,远程重复重放不重建已释放的缓存。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
|
||||
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。已随 0.13.2 发布的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件;终态及时释放写入缓存,远程重复重放不重建已释放的缓存。普通结构升级不重新转换历史或回收正常空闲页,启动页区分结构更新、旧内容转换与实际空间回收。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
|
||||
- [x] **当前会话图片工具**:为图片模型开启会话调用后,支持工具调用的聊天模型可在 Execute 中生成图片、修改上传图片或历史成果,无需另开会话或手动切换图片模型。直连文本模型、本地 OpenCode、Continue、DeepSeek Harness 和托管远程 OpenCode 复用统一服务,Runtime 分配由模型设置派生;图片请求可能产生费用,编辑需服务商支持。详见[实现与验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
@@ -50,7 +50,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
|
||||
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.4`、Desktop `0.13.14`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。Agent `0.13.4` 已发布,当前 Desktop 候选为 `0.13.16`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
|
||||
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
|
||||
@@ -116,7 +116,7 @@
|
||||
任务中心通过状态数量和筛选定位运行中及待处理任务,审批操作保留在对应任务卡片中。
|
||||
- [x] **自动监督设置与范围**:“监督者 > 自动监督”统一管理计划、自动监督报告和建议,支持创建和编辑 Global 或指定一个、多个 Project 的每日、每周计划;工作回顾阅读带日期的统一监督结果及历史。旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置中心不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
|
||||
- [x] **监督者回顾、故事线图谱与活动**:按范围和时间回顾工作,查看保存结果的图谱及来源,确认或修订实体,预览写入本地知识库实体。侧栏可跟随或固定 Conversation、Task,并打开匹配结果。活动合并心跳报告与下游监督的阶段状态,保留失败和旧结果入口。自动阶段只处理新增、修改及未处理的来源片段,无变化时跳过模型;手动回顾重新整理有界历史。回顾只读且禁止工具,模型调用可能产生费用,详见[实施与验证记录](./docs/features/conversation-supervision/progress.md)。
|
||||
- 手动回顾分页处理所选历史,并按批保存事实与来源;支持暂停和从保存进度继续。设置提供并发、超时、批次大小、执行预算和响应容量,活动记录显示实际阶段与已保存批次详情;工作回顾和活动记录使用面板可用全宽。
|
||||
- 手动回顾分页处理所选历史,并按批保存事实与来源;不再按固定时长自动暂停,仍支持主动暂停和从保存进度继续。设置提供并发、单次请求超时、批次大小和响应容量;活动记录突出相连阶段、已保存批次与剩余来源,次要统计可展开查看。工作回顾采用全宽纵向布局,运行状态单独显示,空态不再挤在控件旁。
|
||||
- [ ] **监督后续能力**(规划中):逐事件完整回放、Experiment 目标、人工修改时间序列审计、事件触发及更完整的执行观察仍未完成。当前自动回顾受输入预算和滚动窗口限制,删除报告不会重置处理进度或重建图谱。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
|
||||
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
|
||||
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
|
||||
@@ -143,11 +143,12 @@
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- 当前源码候选为 Desktop `0.13.14`、Agent `0.13.4`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.13.14` 改善监督者分页回顾与续跑、ripgrep 原生搜索、待办布局和远程结果未知后的显式续聊。数据库升级至 schema 47,回退旧客户端需使用升级前完整备份。
|
||||
- 当前 Desktop 候选为 `0.13.16`,Agent `0.13.4` 已发布,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.13.15` 承接未发布的 `0.13.14`,改善监督者分页回顾与续跑、ripgrep 原生搜索、待办布局和远程结果未知后的显式续聊。数据库升级至 schema 47,回退旧客户端需使用升级前完整备份。
|
||||
- Desktop `0.13.16` 分离结构升级与旧历史回收,移除回顾固定时长自动暂停,并改善监督者布局。相较 `0.13.15` 不新增 schema 变更,也不要求更新 Agent。
|
||||
- Agent `0.13.3` 回收空闲 Runtime,并使被替换的 Agent 等已有工作结束后退出;复合包要求 Desktop `0.13.12`,请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。
|
||||
- Agent `0.13.4` 在完成前等待模型交付确认,空闲 Runtime 回收失败不改变已完成结果,并在环境更新后清理已确认停用的旧程序及所属孤儿进程;保留历史、活动及状态不明的安装。独立包要求 Desktop `0.13.14`,请先升级 Desktop,再更新 Host。
|
||||
- 发布准备要求本地全量测试、发布说明校验、类型检查及 lint;候选主分支 CI、生产构建、原生打包及公开发布核验为独立验收步骤。本轮不做本地生产构建、打包或龙芯预览,当前验证状态见[发布准备记录](./docs/development/release-preparation-0.13.14.md)。
|
||||
- Agent `0.13.4` 在完成前等待模型交付确认,空闲 Runtime 回收失败不改变已完成结果,并在环境更新后清理已确认停用的旧程序及所属孤儿进程;保留历史、活动及状态不明的安装。独立包要求 Desktop `0.13.14` 或更新版本;因 `0.13.14` 未发布,请先升级至 Desktop `0.13.15`,再更新 Host。
|
||||
- 发布准备要求全量测试、发布说明校验、类型检查及 lint;候选主分支 CI、生产构建、原生打包及公开发布核验为独立验收步骤。本轮不做本地生产构建、打包或龙芯预览,当前验证状态见[发布准备记录](./docs/development/release-preparation-0.13.16.md)。
|
||||
- Desktop `0.13.12` 和 Agent `0.13.3` 已完成原生发布流程,但不替代开发期真实 Host 场景覆盖。
|
||||
- 验证记录与功能状态分开。存储已有本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;进程复用有 Windows 完整 App 与 Linux x64 Host 证据,PPTX OCR 与空闲内存释放有 Windows 真实文件证据。图片工具已有真实服务商生成/编辑及 Host 传输证据,这些记录未覆盖完整界面自然语言调用和本地/远程切换。候选仍需主分支 CI 及原生发布打包,详见[Runtime 验证记录](./docs/features/assistant-workbar/progress.md)与[图片工具验证记录](./docs/features/conversation-media-generation/progress.md)。
|
||||
- 发布准备前,`9836a4c` 已通过本地 4,858 项测试(67 项跳过)、完整类型检查和 lint;真实服务商画布分析仍未验证。该提交的三个原生 Agent CI 构建通过;桌面 CI `35485936807` 为 4,875 项通过、8 项 Electron 测试失败,均因 Linux 沙箱辅助程序未配置,在页面断言前退出。候选配置该程序并在 Xvfb 下运行测试,不关闭沙箱;候选本地验证、主分支 CI 与原生标签打包分别记录,不在发布准备中运行本地生产构建或打包。
|
||||
@@ -159,7 +160,7 @@
|
||||
|
||||
### 开放接口、团队协作与远程执行
|
||||
|
||||
- [x] **远程 Continue 交付**:已接入 CN 1.5.47 受管包、Agent HTTP-to-ACP 适配、模型路由、项目选择和原生清单。默认包同时携带 OC/CN;Windows 桌面到 Linux x64 已通过安装、真实模型清单更新、长清单、取消及重启恢复验证。Execute 会话 MCP 已通过真实文本模型 Host 往返,图片服务使用替代接收端,不代表真实生图验证。候选 Agent `0.13.4` 需要 Desktop `0.13.14`;正式发布及原生平台验收与 Linux 开发证据分开,详见[验证记录](./docs/features/remote-host/runtime-checklist-validation.md)。
|
||||
- [x] **远程 Continue 交付**:已接入 CN 1.5.47 受管包、Agent HTTP-to-ACP 适配、模型路由、项目选择和原生清单。默认包同时携带 OC/CN;Windows 桌面到 Linux x64 已通过安装、真实模型清单更新、长清单、取消及重启恢复验证。Execute 会话 MCP 已通过真实文本模型 Host 往返,图片服务使用替代接收端,不代表真实生图验证。已发布 Agent `0.13.4` 需要 Desktop `0.13.14` 或更新版本;正式发布及原生平台验收与 Linux 开发证据分开,详见[验证记录](./docs/features/remote-host/runtime-checklist-validation.md)。
|
||||
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
|
||||
+2
-2
@@ -716,13 +716,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 监督者入口使用 `supervisor` 壳层;智能心跳设置位于同一壳层内。
|
||||
- 顶层页签为“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”,使用一套共享 PageTabs,窄窗口单行横向滚动。页签下不重复同名标题,也不保留空标题容器;所选回顾使用“监督结果”标题,历史结果不得标为最新。
|
||||
- 工作回顾只展示统一监督结果、历史选择和紧凑的新回顾控件;结果保留自身范围、覆盖区间和生成时间,图谱读取同一结果 ID。自动监督集中展示当前计划、运行概览、指标、趋势、自动监督报告、记忆与行动建议及心跳运行审计;活动记录只展示统一执行活动、进度和详情。每页保留所属数据的一处刷新入口,范围与时间选择仅作用于新回顾请求。详细布局与阅读规则见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md#应用入口)。
|
||||
- 监督者保留统一页面壳层;阅读与活动子面板分别复用 `--content-reading` 和 `--content-dashboard`,在壳层内左对齐。各面板的工具栏、历史导航、状态和结果共享宽度,不在全宽卡片内部再次居中窄正文。活动筛选按内容紧邻排列,阶段以文字状态行呈现。
|
||||
- 监督者保留统一页面壳层;工作回顾与活动子面板铺满壳层可用内容宽度,与页签左右对齐。工具栏、历史导航、状态和结果共享宽度,不再增加子面板宽度上限。工作回顾按纵向排列,空态独占工具栏下方整行并水平居中。活动筛选按内容紧邻排列,阶段使用编号圆点和连接线,次要统计收进详情;具体布局见[监督者 UI 设计](docs/features/conversation-supervision/ui-design.md)。
|
||||
- 范围明确区分 Global 与指定的一个或多个 Project;Global 与指定项目互斥,多项目选择使用
|
||||
Checkbox,不能依靠进入页面时的当前项目推断。
|
||||
- 状态卡片使用统一状态令牌,不只依赖颜色。
|
||||
- 活动记录先显示运行状态、时间、范围及实际阶段,恢复操作紧邻进度。原始错误默认折叠,展开后在有界面板内滚动并可选择复制;配置归入次要详情。任务层级只展示可查询的记录,缺失阶段明确标注,不能用剩余来源为零推断发布成功。具体阶段与分页规则见[监督者活动设计](docs/features/conversation-supervision/ui-design.md#活动)。
|
||||
- 自动监督的当前状态下显示计划列表,仅保留列表的一个创建按钮;无计划时使用列表的未配置说明,不叠加第二个空态或创建入口。创建与编辑复用同一份表单及共享 Modal 焦点、背景隔离和遮罩样式;未保存关闭须确认,保存失败保留草稿,编辑暂停计划不恢复运行。“执行记录”进入按该计划 ID 筛选的活动页,活动页不重复显示心跳审计列表。
|
||||
- 设置只展示模型超时与并发、分页、分批和执行预算,不含计划、报告、审计或嵌套页签,不显示整页全局徽标或独立刷新行。
|
||||
- 设置只展示模型超时与并发、分页、分批和单次响应容量,不含计划、报告、审计或嵌套页签,不显示整页全局徽标或独立刷新行。
|
||||
- 监督者的设置、帮助、空态及错误文案不得向终端用户展示内部路线图、交付进度或设计与实现缺项;只说明当前行为、适用范围和可执行的下一步。不得因此夸大为提供商全局限流或完整语义识别;真实的运行失败、超时、暂停、来源失效及恢复操作必须保留。
|
||||
- 智能心跳菜单入口是完整配置的权威位置;任务中心和设置中心不得复制同一 CRUD 表单。
|
||||
- 分区记忆协同与唤起条件尚未实现,不得显示占位入口或平行的记忆类型。
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
- 先修新增数据路径,再修历史数据。历史转换与新写入尽量使用同一套转换函数。
|
||||
- 普通 schema 变更使用既有 `PRAGMA user_version` 和 SQLite 事务;
|
||||
已升级 schema 不允许旧客户端强行打开,不通过回写版本号支持降级。
|
||||
- 将结构升级、旧内容转换和空间回收分别判断。结构版本落后不代表需要整理历史;
|
||||
普通更新产生的空闲页也不是旧数据证据。只有明确的旧内容转换才允许检查空闲页并
|
||||
执行 `VACUUM`,后续新增结构迁移不自动继承全库回收。启动页按实际操作显示状态。
|
||||
- 同一次启动的重试可在内存中保留已确认的转换/回收决定;退出后重新根据数据库
|
||||
schema 和未转换内容判断,不为仅剩空闲页增加持久化标记或重复启动回收。
|
||||
- 大数据转换不得同步阻塞 Electron Main 或 Renderer。放到工作线程,在业务连接
|
||||
打开前处理,并使用应用的共享字体、主题与页面组件显示进度和可执行的失败提示。
|
||||
- 根据实测设置有界读取批次与缓存,批次提交后再报告进度。保持 SQLite 事务与
|
||||
@@ -40,6 +45,9 @@
|
||||
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
|
||||
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
|
||||
不能只处理尚未迁移的旧库。
|
||||
仅修改升级调度、不改变存储格式时不增加 schema。回归必须覆盖已完成历史转换的库
|
||||
在后续结构升级时仍有空闲页,确认不重新转换或 `VACUUM`,并验证真实 Worker、
|
||||
同次启动重试和下一次启动的区别。
|
||||
|
||||
### 真实旧库
|
||||
|
||||
@@ -71,5 +79,5 @@
|
||||
表示,并回收空间。具体规则见
|
||||
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
|
||||
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
|
||||
- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
|
||||
- Desktop 0.13.2:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
|
||||
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Desktop 0.13.14 / Agent 0.13.4 发布准备
|
||||
|
||||
后续状态:Desktop 标签流程 `36165805814` 在测试阶段失败,未进入构建或发布;
|
||||
Agent 流程 `36165805884` 成功且 0.13.4 已公开。两个历史标签均保留,
|
||||
桌面恢复版见 [0.13.15 准备记录](./release-preparation-0.13.15.md)。
|
||||
以下为原候选准备时的范围与证据,不代表其桌面发布成功。
|
||||
|
||||
## 范围与审批
|
||||
|
||||
- 用户确认从 0.13.13 升至 0.13.14,现有七个测试修改及三个验证辅助文件一起提交。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Desktop 0.13.15 恢复版准备
|
||||
|
||||
## 发布范围
|
||||
|
||||
- 以已发布的 `v0.13.13` 为用户更新基线,完整承接未发布 `v0.13.14` 的产品变更。
|
||||
历史准备及 Host 验证见 [0.13.14 记录](./release-preparation-0.13.14.md)。
|
||||
- 0.13.14 标签流程 `36165805814` 在测试阶段失败,生产构建、打包与发布均未执行;
|
||||
GitHub Release API 返回 404。不可变标签保留,包内条目改为 0.13.15,
|
||||
不保留重复的未发布说明。
|
||||
- Agent 0.13.4 流程 `36165805884` 成功,三个包已公开,最低 Desktop 要求仍为
|
||||
0.13.14。恢复版满足该最低要求;不重发 Agent、不更改其签名目录或标签。
|
||||
- 用户批准 0.13.15 标题及升级提示调整,其余双语正文沿用已批准内容;
|
||||
要求先推送候选,等 CI 全量验证与生产构建成功后再打标签。
|
||||
- 不构建 LoongArch,不运行本地生产构建、打包或安装包启动探针。
|
||||
- 中英文功能矩阵已复核,同步恢复版本和 Agent 已发布状态,保留其他未验收边界。
|
||||
|
||||
## CI 修复
|
||||
|
||||
`cd27163` 仅改测试和质量规范,不改变产品、Agent、数据库或外部契约:
|
||||
|
||||
- 原生能力清单测试控制第二次响应,等待重试已开始再展开控件,并分别校验响应前后状态。
|
||||
- 浏览器关闭测试在同一个 `waitFor` 中检查两次调用、页签移除和错误清除。
|
||||
- 笔记布局测试显式扫描 fixture 依赖,使用可见的隔离 Electron 窗口执行原生输入,
|
||||
保留 sandbox/contextIsolation,明确检查超时、信号和退出码后才读取结果。
|
||||
|
||||
对应 CI 证据:标签流程有 1 项失败(原生能力按钮未找到);
|
||||
main 流程 `36164698647` 有 2 项失败(关闭错误残留、布局测试约 90 秒后缺少结果文件)。
|
||||
两次失败点不同,不能仅凭本地通过认定 CI 已修复。
|
||||
|
||||
## 验证
|
||||
|
||||
- 本地三个定向用例通过,Electron 布局约 3.46 秒,全部几何和交互断言保留。
|
||||
- 修复提交完整 typecheck、lint 通过。
|
||||
- 本轮没有重复本地全量测试,以新候选 Linux CI 的完整测试结果为准;
|
||||
先前 5,127 项本地通过仅为历史记录。
|
||||
- 无新真实模型调用,未改变 Agent 或 Runtime 生产源码,无需重复 Host 开发探针。
|
||||
- 恢复候选的说明校验、9 项发布说明测试和 `git diff --check` 通过;
|
||||
三处桌面版本一致。逐项比较确认只替换版本及已批准升级提示,
|
||||
0.13.14 无重复条目,Agent lock 仍为 0.13.4。
|
||||
- 主分支 CI、生产构建和标签发布结果待后续核对。
|
||||
@@ -0,0 +1,43 @@
|
||||
# Desktop 0.13.16 发布准备
|
||||
|
||||
日期:2026-09-26。当前为候选,不代表已发布。
|
||||
|
||||
## 范围与批准
|
||||
|
||||
- 用户确认 Desktop 0.13.16、Agent 保持 0.13.4、不构建 LoongArch 预览。
|
||||
- 基线为 `325b702934ba0f0c076f27e926a897267262ab55`,用户明确批准纳入新提交的
|
||||
Supervisor 界面修改。相对已发布 `v0.13.15` 包含:
|
||||
- `e6ac073`:监督回顾持续分批处理,保留主动暂停、单次模型超时和并发。
|
||||
- `4c7193e`:结构升级不自动回收正常空闲页,准确显示操作状态及保留同启动重试。
|
||||
- `325b702`:监督回顾空态、运行状态与活动阶段布局修复。
|
||||
- 用户已批准中英文三项问题修复及两项升级提示,写入 `resources/release-notes.json`;
|
||||
已发布版本说明保留。最终候选 SHA 需另行确认,主分支 CI 通过后才创建新标签。
|
||||
- `FEATURES.md` 和 `FEATURES.zh-CN.md` 已核对并更新当前版本、持续回顾、
|
||||
结构升级及界面行为,删除过时的执行预算暂停描述。
|
||||
|
||||
## 数据与 Agent 边界
|
||||
|
||||
相对 0.13.15 不提高 schema,仍为 47。不新增持久化迁移标记,不改写用户数据库。
|
||||
旧格式仍执行必要转换;普通结构升级不因空闲页执行全库 VACUUM。
|
||||
监督回顾仍只读、无工具,模型调用可能收费;取消固定时长暂停后可连续处理更多批次。
|
||||
|
||||
本轮没有 Agent、Runtime、模型桥、部署锁或远程协议修改;本机与远程历史共用桌面升级
|
||||
实现。监督通过桌面 Ask 编排,不改变 Host 运行路径,不要求 Agent 更新或新 Host 部署。
|
||||
本轮实际模型请求数为 0。
|
||||
|
||||
## 验证
|
||||
|
||||
在独立工作树准备,避免其他工作修改影响候选,复用相同锁定依赖,未复制本机私有环境文件。
|
||||
候选第一轮全量发现 `HeartbeatCenter.test.tsx` 的两种语言仍要求已移除的重复说明存在;
|
||||
改为断言说明不存在,保留范围、操作、标题及页面内容校验,专项 **42 项通过**。
|
||||
|
||||
最终候选完整测试:**5,128 通过、84 跳过、1 失败**。唯一失败为未修改的
|
||||
`mermaid-viewer.electron.test.ts` 子进程退出码 null;首次单独复跑被工具的 150 秒
|
||||
上限中断,随后按用例原有时间窗口复跑 **1 项通过**,覆盖九种显示场景和十次实际 PNG
|
||||
导出。未修改该用例、提高其自身超时或弱化断言,独立复跑不替代全量结果。
|
||||
`npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 均通过。
|
||||
|
||||
先前迁移定向 47 项通过;最终开发期全量为 5,137 通过、74 跳过、两项 Electron
|
||||
失败,失败用例独立复跑两项通过。该记录不是本候选的全量或 CI 通过证明。
|
||||
|
||||
未运行本地生产构建、打包或安装包启动。主分支 CI、六目标原生打包和公开发布核验待完成。
|
||||
@@ -13,6 +13,8 @@
|
||||
处理速度;处理时间取决于历史数据量与磁盘速度,不承诺固定几分钟。用户可以退出,
|
||||
下次启动重新检查并继续转换未完成的数据;完成后回收空闲页并进入应用。失败时留在
|
||||
该页提供重试与退出,磁盘空间不足会明确说明,不删除聊天来腾出空间。
|
||||
以上转换和回收只针对仍需转换的旧格式数据;普通结构升级使用独立状态,不宣称正在
|
||||
清理子任务历史,也不会仅因数据库有空闲页而执行全库回收。
|
||||
|
||||
## 存储规则
|
||||
|
||||
@@ -94,7 +96,10 @@ Renderer 合并时独立接收 Main 的置顶属性,即使本地正文更新
|
||||
|
||||
启动检查只根据旧 schema 或仍未转换的笔记正文决定是否进入升级流程;正常写入、更新
|
||||
与删除产生的 SQLite 空闲页不是旧格式证据。迁移后新增数据使用当前格式,不因正常使用
|
||||
再次迁移。同一次启动已进入的迁移流程可重试压缩;若正文已转换、仅压缩中断,退出后
|
||||
再次迁移。进入升级流程后仍分别判断结构、笔记转换与空间回收,不把旧 schema 当作
|
||||
回收许可。回收只用于实际存在旧子任务事件(schema < 35)、旧活动记录(schema < 44)
|
||||
或旧笔记正文的转换;例如 schema 45/46 升到当前结构不会自动转换历史或 `VACUUM`。
|
||||
同一次启动在内存中保留这一决定,只有原本需要回收的流程可重试压缩;若正文已转换、仅压缩中断,退出后
|
||||
重开不单凭空闲页重新进入迁移,剩余页由 SQLite 后续写入复用。不新增持久化迁移标记。
|
||||
|
||||
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
|
||||
@@ -108,8 +113,8 @@ Renderer 合并时独立接收 Main 的置顶属性,即使本地正文更新
|
||||
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
|
||||
|
||||
子任务整理对应 schema 35;schema 34 用户也会执行该整理,成功后后续启动直接跳过。
|
||||
继续复用 schema 34 的子任务事件部分索引和同一差量表示。当前业务初始化随后在事务中
|
||||
升级为 schema 36,新增 `pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1))`,
|
||||
继续复用 schema 34 的子任务事件部分索引和同一差量表示。业务初始化随后在事务中
|
||||
执行后续结构迁移,其中 schema 36 新增 `pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1))`,
|
||||
已有会话默认未置顶,保留原消息与时间戳;失败回滚本次字段迁移。
|
||||
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
|
||||
升级前备份,不要修改 `user_version` 强行绕过版本检查。
|
||||
@@ -120,6 +125,8 @@ Renderer 合并时独立接收 Main 的置顶属性,即使本地正文更新
|
||||
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
|
||||
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
|
||||
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
|
||||
- 覆盖已完成历史转换、但正常聊天更新产生空闲页的后续结构升级:无转换进度、无
|
||||
`VACUUM`,会话、笔记、任务事件与完整性不变;验证真实 Worker 和同次启动重试。
|
||||
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
|
||||
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
|
||||
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-26 结构升级与历史回收分离(未发布)
|
||||
|
||||
- schema 45/46 的回归先复现普通结构升级执行 `VACUUM`,修复后不转换历史、
|
||||
不回收正常空闲页,保留聊天、笔记、事件及 SQLite 完整性。
|
||||
- 启动决定在内存中分别保留笔记转换与回收需求,Worker 重试复用原决定;
|
||||
不增加 schema 或持久标记,不修改正常写入。规则见
|
||||
[旧库升级](./execution-history-storage.md#旧库升级)。
|
||||
- 真实 Node Worker 验证结构升级、旧笔记转换、取消与重试、文件大小和内容保留;
|
||||
启动调度回归验证允许/禁止回收的两种重试,中英文页面区分实际操作。
|
||||
六个定向测试文件 **47 项通过**,类型检查、lint 和 `git diff --check` 通过。
|
||||
- 完整 `npm test`:**5,138 通过、74 跳过、1 失败**。未修改的
|
||||
`tests/magic-notes-layout.electron.test.ts` 原生拖拽断言失败:首次索引列保持 168,
|
||||
单独复跑则 AI 列保持 280,均未达到拖动后的预期宽度。该 fixture 直接加载
|
||||
Magic Notes 和模拟 API,不运行本次存储升级路径。随后修正测试的原生输入同步:
|
||||
等待焦点、拖拽建立、宽度更新及结束,并明确左键按下状态,未弱化断言或改变产品
|
||||
布局;独立复跑及随后完整测试中的该用例通过。
|
||||
- 最终完整重跑:**5,137 通过、74 跳过、2 失败**,迁移相关及布局拖拽均通过。
|
||||
`inline-help.electron.test.ts` 超时,`overlay-layering.electron.test.ts` 子进程退出码
|
||||
为 null;这两个未修改的用例随后一起独立复跑 **2 项通过**,未提高超时或修改断言。
|
||||
最终 typecheck、lint、`git diff --check` 均通过。独立复跑不替代完整测试结果,
|
||||
不宣称本次全量或 CI 全绿。
|
||||
- 本机与托管 SSH 历史共用 Desktop 数据库升级路径;Agent、Runtime、模型桥和
|
||||
远程协议未变,不需要部署 Host 补丁。本次未调用模型,未改写用户数据库,
|
||||
尚无本次修复的 CI 或发布验证。
|
||||
|
||||
## 2026-09-21 重复历史迁移补修(Desktop 0.13.9 候选)
|
||||
|
||||
- 0.13.8 的检查仍在已有笔记时把 `freelist_count > 0` 当作迁移依据;正常聊天更新
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
| [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 |
|
||||
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
|
||||
|
||||
生产入口已接通完整来源分页、叶子持久化、预算暂停、活动页继续及批次详情;设置可调页大小、批次正文/消息上限、软预算、两个模型超时和共享并发。实际合同及限制见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。真实 DeepSeek 验证已覆盖一个完整的 27 条消息会话;较早的 129 条消息实验仍未完成。两次实验共 5 次付费请求,详情见进度。原始 AbortError 原因尚未由日志确定。
|
||||
生产入口已接通完整来源分页、叶子持久化、持续处理至完成、活动页主动暂停/继续及批次详情;设置可调页大小、批次正文/消息上限、单次响应容量、两个模型超时和共享并发。实际合同及限制见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。真实 DeepSeek 验证已覆盖一个完整的 27 条消息会话;较早的 129 条消息实验仍未完成。两次实验共 5 次付费请求,详情见进度。原始 AbortError 原因尚未由日志确定。
|
||||
|
||||
实施状态以代码和[实施进度](./progress.md)为准。逻辑待定项见[逻辑设计](./logic-design.md),架构边界见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
|
||||
## 已验证
|
||||
|
||||
### 2026-09-26 移除回顾自动时间预算暂停
|
||||
|
||||
对应 FR-S4、FR-S6、FR-S10。生产服务移除提取循环和导航合并中的整次执行期限判断,持续处理至完成;已保存运行中的 `executionSeconds` 继续兼容读取,但不再控制暂停。设置页移除该控件,中英文帮助和活动配置改为持续处理说明。单次模型超时、共享并发、逐批保存、主动暂停及失败续跑保持原路径,具体合同见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。主动暂停仍中断在途请求,仅保留此前已保存批次。
|
||||
|
||||
`npx vitest run src/main/assistant/supervision-review.test.ts src/renderer/src/SupervisionReviewSettings.test.tsx`:13 项通过。受控时钟超过保存的执行预算后,三个叶子及两个导航合并仍完成并发布;另验证主动暂停后复用成功批次和原分块配置、中英文设置不再出现整理时长控件。测试使用隔离 SQLite 和可控摘要器,不代表真实模型长时间运行实测。
|
||||
|
||||
`npm run typecheck`、六个修改 TS/TSX 文件的定向 ESLint 和 `git diff --check` 通过。未运行全量测试,真实模型调用 0 次。核对 Main 的生产工厂接线,监督使用不携带项目的 `resolveRequestRuntime({ workMode: 'ask' })`;本次仅改变桌面回顾编排,不修改 Agent、远端 Runtime 或桌面到 Agent 协议。未提交或推送。
|
||||
|
||||
|
||||
### 2026-09-24 长摘要与内容区对齐
|
||||
|
||||
针对全局七天回顾的 `summary max 2000` 报错,代码确认导航输出 schema 的 `max(2000)`、服务层对叶子及导航的二次 2000 字符检查,以及模型提示词中的同值要求。另发现监督旧摘要通过 SQL `substr` 和 JavaScript `slice` 截断。以上监督路径限制已移除;生成内容和事实集合的合同见[生产调度合同](./review-scheduling-design.md#已接入的调度与存储)。未读取本次 16:03 失败的原始模型响应,不能据此声明该运行的具体失败节点。
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
日期:2026-09-24。状态:生产分块、持久化续跑、设置及活动入口已接线;27 条消息的完整会话通过真实模型验证,较早的 129 条消息实验仍未完成。当前实现以第 0 节为准,第 1 节保留改造前基线,第 2 至 11 节保留目标设计,第 12 节为早期隔离原型证据。
|
||||
|
||||
本文针对全局七天 `supervision:run` 出现 `AbortError` 的反馈,设计完整分页、证据分块、模型任务调度和逐层汇总。用户已明确要求预算耗尽或失败后保留成功批次及位置,重启后可继续,不重复计算成功叶子。生产接线复用 `SupervisorService`、现有数据库及 `review_checkpoints`;不另建定时调度器。schema 47 的实现与目标差异见第 0 节。
|
||||
本文针对全局七天 `supervision:run` 出现 `AbortError` 的反馈,设计完整分页、证据分块、模型任务调度和逐层汇总。用户已明确要求主动暂停或失败后保留成功批次及位置,重启后可继续,不重复计算成功叶子。生产接线复用 `SupervisorService`、现有数据库及 `review_checkpoints`;不另建定时调度器。schema 47 的实现与目标差异见第 0 节。
|
||||
|
||||
产品范围仍由[监督者产品设计](./supervisor-prd.md)定义;本设计对应 FR-S4、FR-S6、FR-S10 及 US-S12、US-S26、US-S27。既有规则见[逻辑设计](./logic-design.md#自动增量与手动重看),实现架构见[技术设计](./technical-design.md),已验证事实见[实施进度](./progress.md)。第 0 节以当前代码为准,其余目标须逐项核对实现状态;大范围回顾仍需独立性能验证。
|
||||
|
||||
2026-09-26 修订:回顾持续分页、提取和汇总至完成,不再按整次运行的时间预算自动暂停。下文历史草案中的执行软预算、整次执行硬期限及按时间预算续跑方案已撤销;单次模型超时、并发限制、用户暂停和失败续跑仍保留。
|
||||
|
||||
## 0. 生产接线与剩余边界
|
||||
|
||||
Main 的手动 IPC 和心跳完成回调均使用 `createProductionSupervisorService`,该工厂连接 `SupervisorService`、`SupervisionReviewStore`、共享模型池及实际 ask Runtime。生产路径不再调用旧的 20 会话 × 20 消息 collector,也不使用旧服务分支的 100 条 / 48,000 字符裁剪。旧的有界服务注入方式仍供原有测试和实验脚本使用;心跳报告本身继续使用原报告预算。
|
||||
@@ -16,7 +18,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
|
||||
派发按项目及项目内会话轮转,每个会话同一轮至多一个批次,会话内按 sequence/source 排序。批次跨数据库页累计;长消息未装入部分保留待处理。每片最多 8,000 UTF-16 单元、每批最多 50 片,均为封块条件,不是范围上限。位置采用零基、左闭右开的 Unicode 码点;消息 locator 保存 messageId、conversationId、projectId、role、sequence、source、revision、start、end、length。知识引用另保留原 locator 及所属消息,任务使用当前状态和区间内创建/完成时间。
|
||||
|
||||
成功叶子的输出、原文片段和连续 offset 在同一事务提交。失败或软预算到期保留此前批次。继续操作使用同一 run ID、时间范围和分块配置;重启后可继续。手动运行不读写自动 checkpoint。自动运行先恢复同 scope 的未完成 run;仅在全部来源连续覆盖、导航完成、最终发布事务成功时写入 `stage=supervisor` 的 checkpoint。空范围无需模型。
|
||||
成功叶子的输出、原文片段和连续 offset 在同一事务提交。失败或用户主动暂停保留此前批次。继续操作使用同一 run ID、时间范围和分块配置;重启后可继续。手动运行不读写自动 checkpoint。自动运行先恢复同 scope 的未完成 run;仅在全部来源连续覆盖、导航完成、最终发布事务成功时写入 `stage=supervisor` 的 checkpoint。空范围无需模型。
|
||||
|
||||
叶子输出仍使用现有事件、实体、变化和关系契约,并校验引用与候选身份。会话、项目、scope 使用二叉递归合并导航摘要,单子透传,总共 N 个叶子至多 N-1 次合并。已成功导航节点按孩子 ID 保存,发布失败后可复用。叶子、导航及最终结果的生成文本不设字段字符上限,事实数组不设独立数量上限;导航节点仍不得生成替代叶子的事件或实体。发布事务逐页读取叶子,保存既有历史/图谱结构,保持历史定位、来源及用户确认状态;完整叶子同时通过独立批次接口分页读取。
|
||||
|
||||
@@ -24,7 +26,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
|
||||
导航使用独立的严格输出 schema,要求 `summary`、`changeDigest`、`openItems`;四个事实数组允许省略或为空,非空数组、错误类型和额外字段仍拒绝。验证通过后才在内部补空数组,以复用现有导航存储形状。叶子继续要求四个数组齐全,不将缺字段当作成功;来源、事件实体、变化实体和关系端点均校验。继续时还会核对已保存叶子的引用,导航不覆盖其独立事实。
|
||||
|
||||
`supervision:pause` 将父 signal 传入排队及在途模型;取消后的输出不提交。`supervision:resume` 恢复已保存 run;`supervision:batches` 默认每页 10 批、最多 20 批,返回完整叶子及来源。活动返回已保存批次、码点数、剩余来源、当前阶段、排队或执行中的模型任务数及已保存配置。软预算到期会等待已派发批次各自结束,随后显示 `paused`;完成判定不依赖摘要措辞。
|
||||
`supervision:pause` 将父 signal 传入排队及在途模型;取消后的输出不提交。`supervision:resume` 恢复已保存 run;`supervision:batches` 默认每页 10 批、最多 20 批,返回完整叶子及来源。活动返回已保存批次、码点数、剩余来源、当前阶段、排队或执行中的模型任务数及已保存配置。提取和导航汇总持续运行至完成,不按累计运行时长暂停;用户暂停会中断在途请求,仅保留已保存批次。完成判定不依赖摘要措辞。
|
||||
|
||||
当前阶段以 `phase` 写入既有 `supervision_review_runs.state_json`,取值为 `collecting / extracting / summarizing / saving`;无新表或迁移。它记录服务最后进入的阶段,失败和重启后保留;完成仍由 `supervision_runs.status` 的发布事务决定。活动的 `navigationNodes` 从已保存导航表计数,`inFlight` 仍来自进程内计数。旧运行没有 phase 时不回填推测值。清单和最终发布为同步事务,短阶段不保证被轮询看到,清单事务失败也不会留下部分阶段记录。批次接口只有成功记录,没有逐个失败/在途任务或项目、会话聚合状态;界面仅按分页返回的真实归属组织详情。
|
||||
|
||||
@@ -35,12 +37,12 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
| `supervisionReview.pageSize` | 50 | 1..200 | 每次来源元数据页条数;不改变最终集合或批次边界 |
|
||||
| `supervisionReview.batchCharacters` | 8000 | 1000..16000 | 每批新正文 UTF-16 单元,不含提示、候选及 JSON |
|
||||
| `supervisionReview.batchMessages` | 20 | 1..50 | 每批不同消息数;任务等无消息 ID 的来源单独计数 |
|
||||
| `supervisionReview.executionSeconds` | 300 | 30..3600 | 每次执行软预算,继续后重新计时;到期停止新增工作 |
|
||||
| `supervisionReview.executionSeconds` | 300 | 30..3600 | 旧设置兼容字段,执行时忽略,设置页不再展示;恢复旧 run 同样不按此值暂停 |
|
||||
| `supervisorOrganizeTimeoutSeconds` | 240 | 30..600 | 创建回顾时冻结,节点获槽并解析 Runtime 后计时 |
|
||||
| `heartbeatReportTimeoutSeconds` | 240 | 30..600 | 维持报告发起时冻结及既有租约规则 |
|
||||
| `supervisorModelConcurrency` | 1 | 1..4 | 回顾保存接纳并发;共享池上限仍随应用设置实时变化,实际执行取较小值 |
|
||||
|
||||
应用仍默认关闭。新参数由共享 Zod 合同、ApplicationSettingsStore、App 设置回调和 Renderer 表单保存;没有放置尚未生效的高级字段。
|
||||
应用仍默认关闭。除上述旧兼容字段外,参数由共享 Zod 合同、ApplicationSettingsStore、App 设置回调和 Renderer 表单保存;没有放置尚未生效的高级字段。
|
||||
|
||||
### 未完成与实测限制
|
||||
|
||||
@@ -221,7 +223,7 @@ run(request):
|
||||
admit bounded leaf tasks, round-robin by project and conversation
|
||||
run admitted leaves in a shared worker pool
|
||||
validate and persist each successful batch with facts, provenance and resume positions
|
||||
if budget expires, a task fails or cancellation arrives: stop; retain batches; mark incomplete
|
||||
if a task fails or cancellation arrives: stop; retain batches; mark incomplete
|
||||
continue paging all project/conversation/message/source groups until exhausted
|
||||
reduce within conversation, then project, then requested scope
|
||||
pass through singleton groups; reduce oversized groups in bounded rounds
|
||||
@@ -231,7 +233,7 @@ run(request):
|
||||
return completed only after publication; otherwise return resumable incomplete status
|
||||
```
|
||||
|
||||
采集与叶子执行可交错,但入队和在飞任务有界,不使用对全部来源的 `Promise.all`。每批完成后持久化,再接纳下一批;时间不足就暂停。首次派发前用有界探测判断是否直通;穷尽范围后确实只有一个叶子时直接生成最终输出,通过校验后只调用一次模型。因预算只接纳一个叶子时仍是未完成 run。空范围及自动 `no_change` 必须在全部相关目录/来源页检查完毕后判定,不调用模型、不把预算未读误作无变化。
|
||||
采集与叶子执行可交错,但入队和在飞任务有界,不使用对全部来源的 `Promise.all`。每批完成后持久化,再接纳下一批,持续至全部来源及汇总完成。首次派发前用有界探测判断是否直通;穷尽范围后确实只有一个叶子时直接生成最终输出,通过校验后只调用一次模型。因预算只接纳一个叶子时仍是未完成 run。空范围及自动 `no_change` 必须在全部相关目录/来源页检查完毕后判定,不调用模型、不把预算未读误作无变化。
|
||||
|
||||
叶子有多个时输出受限摘要卡及结构化事实,Main 保存卡片 token 到原文片段的映射。会话只有一个卡片时透传,项目只有一个会话摘要时透传,全局只有一个项目摘要时透传。不得为了层级外观额外调用模型。边界必须标清项目和会话,跨项目汇总仍使用原请求 scope 的候选实体集。
|
||||
|
||||
@@ -283,7 +285,7 @@ run(request):
|
||||
|
||||
建议扩展 `coverage_json`:是否读完冻结范围、已处理新字符和片段数、覆盖项目/会话数、是否仍有来源、停止原因及恢复引用。精确片段继续用 locator,恢复记录关联批次输出,不仅保存一个总计数。总数未知时显示“仍有未读内容”,不用动态发现数充当固定分母。结果读取合同需显式暴露 coverage;当前数据库查询了 coverage,但共享结果 schema 未返回它。
|
||||
|
||||
手动和自动均须跨重启继续同一未完成逻辑 run。Main 根据持久化 run ID 读取冻结范围、成功输出及位置,复查来源版本,恢复剩余页和未成功汇总;Renderer 不能自报已处理 offset。手动继续不污染自动位置;自动恢复先于创建同 scope 新 run。恢复后的单次执行重新获得执行预算,但不丢失既有进度。
|
||||
手动和自动均须跨重启继续同一未完成逻辑 run。Main 根据持久化 run ID 读取冻结范围、成功输出及位置,复查来源版本,恢复剩余页和未成功汇总;Renderer 不能自报已处理 offset。手动继续不污染自动位置;自动恢复先于创建同 scope 新 run。恢复后持续处理剩余工作,不按旧执行预算暂停,也不丢失既有进度。
|
||||
|
||||
持久化续跑及手动“继续”入口是本设计的验收要求,不能以建议缩小范围替代。恢复记录的保留期限、主动放弃后的清理规则和具体 schema 尚未确定;不得在 run 未完成时自动删除唯一成功输出或继续所需位置。显式放弃可以终止后续工作,但不能标为完整覆盖。
|
||||
|
||||
@@ -297,7 +299,7 @@ run(request):
|
||||
|
||||
若后续决定让心跳只触发监督、报告从同一结构化结果投影,必须先定义记忆建议和任务建议的共同输出契约、各自成功语义及旧报告展示,另行修改 FR-S4 和 US-S27;本轮不把该产品调整藏在调度重构里。也不使用多阶段事务将两个结果强行绑定。
|
||||
|
||||
报告先获共享槽位再领取租约,并在实际模型请求开始前刷新;报告完成或无变化提交后释放槽位,再执行下游监督。领取与续租条件见当前控制清单。未来整次执行预算不能套到报告租约,也不能在报告完成前等待整棵监督树。现有 tick 防重入不替代共享池上限;监督过长会延迟到期检查,拟通过有界执行和持久化续跑控制,不另建后台调度服务。
|
||||
报告先获共享槽位再领取租约,并在实际模型请求开始前刷新;报告完成或无变化提交后释放槽位,再执行下游监督。领取与续租条件见当前控制清单。未来整次执行预算不能套到报告租约,也不能在报告完成前等待整棵监督树。现有 tick 防重入不替代共享池上限;监督过长仍可能延迟到期检查;移除自动时间预算暂停不改变 tick 的串行等待关系,不另建后台调度服务。
|
||||
|
||||
## 10. 活动与结果展示
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ FR-S1:将现有智能心跳入口整合、升级为监督者,不增加另一
|
||||
|
||||
应用默认关闭,用户显式启用后才接受新的回顾工作;关闭时保留计划和历史。默认值、已保存选择、自动运行条件及在途运行边界由[应用启停与执行](./logic-design.md#应用启停与执行)统一定义。
|
||||
|
||||
FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置只管理超时、并发、分页、分批及执行预算,不嵌套次级菜单。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。
|
||||
FR-S2:应用包含“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”五个主视图。工作回顾提供统一监督结果阅读、历史选择和手动新回顾,结果显示自身范围与日期;自动监督集中展示计划状态、范围、周期及下次执行时间,以及运行概览、指标、趋势、自动监督报告、待处理记忆与行动建议和心跳审计,创建和编辑使用同一份 Modal 表单;活动记录展示统一实际执行、进度、详情及结果入口,支持按计划 ID 筛选;设置只管理超时、并发、分页、分批及单次响应容量,不嵌套次级菜单。计划保存失败保留草稿,关闭未保存修改须确认;编辑已暂停计划保持暂停,创建须显式保存并启用。原有运行记录及应用、存储标识保留。
|
||||
|
||||
当前设置还支持分别调整报告与监督整理的模型超时,以及两者共用的监督并发数。并发设置不限制普通聊天,也不保证避免上游限流;排队不消耗模型阶段时限,降低并发不打断在途请求。取值及生效规则由[当前控制清单](./review-scheduling-design.md#当前超时与调度控制清单已实现)定义。
|
||||
|
||||
FR-S10:活动同时覆盖手动监督回顾、定时心跳及用户立即运行的心跳。每条记录显示触发方式、范围、开始与结束时间、运行状态、失败原因和已有结果入口。同一次心跳的报告和下游监督回顾合为一条活动,报告保存成功不能掩盖下游仍在运行、暂停或失败。活动读取不调用模型,关闭应用后不查询活动。新批次运行支持暂停及继续,保留成功批次;人工修改的时间序列审计尚未实现,历史缺失不得猜测补齐。
|
||||
|
||||
FR-S2:分页只控制数据库读取批量,不能限制最终处理范围。当前已开放来源页大小、每批正文/消息上限、执行软预算;这些配置在新建回顾时保存,继续沿用。当前字段和未来高级参数的边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界),交互见 [UI 设计](./ui-design.md#算法配置设置目标)。完整覆盖、来源校验与事实保留不作为可关闭选项。
|
||||
FR-S2:分页只控制数据库读取批量,不能限制最终处理范围。当前已开放来源页大小、每批正文/消息上限;这些配置在新建回顾时保存,继续沿用。单次响应容量按当前设置生效。回顾持续处理至完成,不按累计运行时长自动暂停。当前字段和未来高级参数的边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界),交互见 [UI 设计](./ui-design.md#算法配置设置目标)。完整覆盖、来源校验与事实保留不作为可关闭选项。
|
||||
|
||||
## 范围与触发
|
||||
|
||||
@@ -37,7 +37,7 @@ FR-S5:会话右侧在跟随模式下展示当前会话的监督反馈;固定
|
||||
|
||||
FR-S6:监督者可提出整理与修改建议;已有会话监督的观察边界继续生效。自动更新图谱不能静默覆盖用户修改的描述、已确认的关系或已有知识库正文。取消整理后保留上次结果;失败说明受影响范围并允许重试,部分结果不得显示为完整回顾。
|
||||
|
||||
FR-S4、FR-S6、FR-S10 的验收要求:完整分页处理冻结范围内的入选来源;叶子事实和来源完整保留,摘要用于导航。预算耗尽或失败后保存成功批次与位置,重启后继续,不重复计算仍有效的成功批次;未读完或汇总未成功不能标完成。该续跑要求已进入生产接线,当前实现、实测范围与剩余差异见[回顾分块调度](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
||||
FR-S4、FR-S6、FR-S10 的验收要求:完整分页处理冻结范围内的入选来源;叶子事实和来源完整保留,摘要用于导航。用户主动暂停或失败后保留成功批次与位置,重启后继续,不重复计算仍有效的成功批次;未读完或汇总未成功不能标完成。该续跑要求已进入生产接线,当前实现、实测范围与剩余差异见[回顾分块调度](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
||||
|
||||
## 与知识库的关系
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
`supervisorModelConcurrency` 为 1 至 4 的整数,默认 1。报告和监督整理共用 Main 内 `SupervisionModelPool` FIFO 池,普通聊天不入池;降低上限不终止在途请求。报告槽位从领取前持有至报告完成或无变化提交后,在下游监督开始前释放,避免并发 1 时相互等待。监督服务外层运行仍串行;该池不是完整分块调度器,也不提供跨聊天与监督的提供商全局并发、RPM、TPM 或 Retry-After 控制。
|
||||
|
||||
报告租约为 `max(300, reportTimeoutSeconds + 60)` 秒,300 是下限;每次到期领取最多一条。Runtime 解析后、模型请求开始前按冻结超时刷新租约,数据库要求运行 ID、owner、attempt、claimed 状态和未过期条件均匹配,否则拒绝启动模型。直连模型独立的 600 秒传输超时、网络重试、心跳重领及轮询不属于用户阶段时限。设置 UI 提供两个超时、共享并发及分页/分批/软预算字段。
|
||||
报告租约为 `max(300, reportTimeoutSeconds + 60)` 秒,300 是下限;每次到期领取最多一条。Runtime 解析后、模型请求开始前按冻结超时刷新租约,数据库要求运行 ID、owner、attempt、claimed 状态和未过期条件均匹配,否则拒绝启动模型。直连模型独立的 600 秒传输超时、网络重试、心跳重领及轮询不属于用户阶段时限。设置 UI 提供两个超时、共享并发及分页/分批/单次响应容量字段;整次回顾持续处理至完成,旧 `executionSeconds` 不再参与执行判断。
|
||||
|
||||
schema 47 的四张批次相关表使用外键随监督运行清理,未完成运行不新增自动过期删除规则。启动时原有 running 记录会标 failed,但已保存批次和配置保留,活动页仍可继续。发布逐页读取叶子并复用现有结果/来源/图谱写入,在同一事务更新完成状态和自动 checkpoint。
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
当前应用页只使用一套共享 PageTabs:“工作回顾 / 故事线图谱 / 自动监督 / 活动记录 / 设置”(英文活动标签为 Activity records)。自动监督独立管理计划;设置只显示模型超时与并发、回顾算法,不含计划、报告、审计或内部页签,也不显示整页全局徽标或独立刷新行。计划范围仍在表单和每条计划内显示。整个页面只有一个一级标题;监督者 bridge 不可用时显示明确错误,设置仍可访问,不回退到旧心跳概览。共享页签保留方向键、Home/End、游标焦点和单一激活面板,窄窗口横向滚动。
|
||||
|
||||
工作回顾只读取统一监督结果。顶部为紧凑的“新回顾”范围、时间、发起与刷新控件,不新增 Modal;简短帮助说明选择仅作用于新请求。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。
|
||||
工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。
|
||||
|
||||
新回顾运行中保留已显示结果及其日期、范围,禁用重复提交,并显示简短状态和活动记录入口;该入口清除计划筛选并聚焦活动页签。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时显示手动回顾入口;自动报告是否为空不影响此判断。
|
||||
新回顾运行中保留已显示结果及其日期、范围,禁用重复提交,并显示简短状态和活动记录入口;该入口清除计划筛选并聚焦活动页签。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。
|
||||
|
||||
## 活动
|
||||
|
||||
文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动说明只描述手动回顾、自动监督执行及自动刷新;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。
|
||||
文案遵循根目录 [监督者文案规则](../../../UI-DESIGN.md#134-智能心跳):不把内部路线图、交付状态或实现缺项展示给用户。活动页不常显重复说明;真实错误、失败和暂停状态仍展示,并保留恢复操作。事实详情提示展开批次并对照原文核对模型内容,不承诺完整语义识别;图谱说明实体状态以所选回顾结果为准。
|
||||
|
||||
对应 FR-S10、US-S26。活动按开始时间倒序,每页 50 条,提供较新/更早记录和刷新。列表保留触发方式、执行范围、开始/结束时间、实际状态、阶段状态、错误及已保存摘要。没有结果时不显示结果按钮;有监督结果时可分别进入该结果的工作回顾和故事线图谱,焦点移到目标页签。超过最近 20 条的结果仍按 ID 读取,不能回退最新结果。
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
|
||||
加载、空记录和读取失败分开显示;读取失败保留已显示记录并提供就地重试。活动页面激活时,存在运行中记录则在上次读取结束 2 秒后刷新,否则 10 秒后刷新;每次最多一个请求。切换顶层页签、离开监督者或关闭应用后停止定时器,迟到响应不更新界面。错误后等待用户重试,不持续重发失败查询。
|
||||
|
||||
活动列表与工具栏铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设仪表盘宽度上限。范围、计划筛选、清除筛选和刷新紧邻排列,空间不足时换行。列表使用共享表面、边框、状态颜色和按钮。每条运行首先显示类型、状态、开始时间和范围;“收集来源 / 整理批次 / 合并摘要 / 发布回顾”紧随其后,宽窗口使用按内容排布的紧凑状态行,窄于 720px 时两列,不为每个阶段铺设独立色块卡片。阶段读取已保存位置和真实运行状态,剩余来源为零不表示发布成功。旧运行没有阶段字段时显示“未记录”,不猜测失败位置;已提交来源清单可确认收集结束。无变化运行不显示新结果跳转,后续无需执行的阶段单独标注。
|
||||
活动列表与工具栏铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设仪表盘宽度上限。范围、计划筛选、清除筛选和刷新紧邻排列,空间不足时换行。列表使用共享表面、边框、状态颜色和按钮。每条运行首先显示类型、状态、开始时间和范围;恢复与事实操作紧随其后,再展示“收集来源 / 整理批次 / 合并摘要 / 发布回顾”。阶段使用编号圆点和连接线,宽窗口四项等宽,窄于 720px 时两列;当前及已完成阶段突出显示,不为每个阶段铺设独立色块卡片。阶段读取已保存位置和真实运行状态,剩余来源为零不表示发布成功。旧运行没有阶段字段时显示“未记录”,不猜测失败位置;已提交来源清单可确认收集结束。无变化运行不显示新结果跳转,后续无需执行的阶段单独标注。
|
||||
|
||||
阶段下显示已保存批数、已读字符数、剩余来源和已保存导航合并数,运行中另显示排队或在途数。字符进度按码点统计,界面不展示 Unicode 术语。恢复操作先于详情:运行中可暂停,失败或暂停后沿用同一运行继续;已记录合并、发布阶段时分别显示“继续合并摘要”“重试发布”。成功叶子保留。来源失效时显示新建回顾提示,不提供无效继续。原始错误默认折叠,展开后的完整文本最多占 240px 高度,可滚动、选择复制和键盘聚焦;错误与恢复操作位于批次树之前。结束时间、覆盖区间、报告状态和已有摘要收进“运行详情与配置”,已保存配置在其中再次折叠。
|
||||
阶段下突出显示已保存批数和剩余来源,运行中另显示合计的排队或在途数,不拆分未经接口提供的数量。已读字符数和已保存导航合并数放入默认折叠的“运行详情与配置”。字符进度按码点统计,界面不展示 Unicode 术语。恢复操作先于详情:运行中可暂停,失败或暂停后沿用同一运行继续;已记录合并、发布阶段时分别显示“继续合并摘要”“重试发布”。成功叶子保留。来源失效时显示新建回顾提示,不提供无效继续。原始错误默认折叠,展开后的完整文本最多占 240px 高度,可滚动、选择复制和键盘聚焦;错误与恢复操作位于批次树之前。结束时间、覆盖区间、报告状态和已有摘要收进“运行详情与配置”,已保存配置在其中再次折叠。
|
||||
|
||||
“已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾软预算暂停后在工作回顾页提示去活动继续,不显示完成通知。
|
||||
“已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾主动暂停后在工作回顾页提示去活动继续,不显示完成通知。
|
||||
|
||||
## 自动监督设置
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
并发说明明确报告和监督整理共用上限,排队不计入模型超时;普通聊天不受此设置限制,但仍与监督竞争上游额度。表单不宣称提供商全局并发、RPM、TPM 或 Retry-After 限流已实现。
|
||||
|
||||
模型超时不包含数据收集与保存,直连模型另有独立 600 秒传输超时。“回顾算法”表单提供“每次读取来源条数”“每批文本量”“每批消息数”和“单次整理时长”,显示默认值及合法范围,使用“保存回顾算法”独立提交。文本量内部仍按 UTF-16 单元计算,界面不要求用户理解编码单位。保存经 App 的现有设置回调进入共享设置存储,加载/保存/锁定时禁用,失败保留草稿。取值以[当前设置合同](./review-scheduling-design.md#当前设置合同)为准。帮助说明执行时间达到软预算后,正在处理的批次完成并保存,再暂停,可从活动记录继续;已保存配置用“同时处理任务数”解释并发,说明实际并发还受当前监督设置限制。
|
||||
模型超时不包含数据收集与保存,直连模型另有独立 600 秒传输超时。“回顾算法”表单提供“每次读取来源条数”“每批文本量”“每批消息数”和“单次响应容量”,显示默认值及合法范围,使用“保存回顾算法”独立提交。文本量内部仍按 UTF-16 单元计算,界面不要求用户理解编码单位。保存经 App 的现有设置回调进入共享设置存储,加载/保存/锁定时禁用,失败保留草稿。取值以[当前设置合同](./review-scheduling-design.md#当前设置合同)为准。帮助说明回顾持续处理至完成,每批保存进度,可从活动记录主动暂停并继续;已保存配置用“同时处理任务数”解释并发,说明实际并发还受当前监督设置限制。
|
||||
|
||||
对应 US-S22 至 US-S24。“自动监督”先显示计划列表,点击创建或编辑才打开 Modal;复用原表单,不保留页面内副本。表单展示名称、项目范围、每日或每周频率、指定时间、回顾窗口(1 至 720 小时)和历史保留(1 至 365 天);每周显示星期选择。时区常显,新建使用本机时区,编辑保留计划原时区。列表上方帮助说明选择每日或每周时间、保存并启用后自动运行。
|
||||
|
||||
@@ -52,9 +52,9 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
以下高级分组为目标交互;已接通的四项算法控件以上节为准。其余参数引用[设置配置清单](./review-scheduling-design.md#4-设置配置清单),不在 UI 文档另设数值,不增加一级页面。
|
||||
|
||||
普通配置常显消息每页条数、监督并发、报告/整理模型超时和单次执行软预算。高级配置按“来源与分块”“上下文与输出”“汇总与内存”“超时与续跑”“上游额度”分组。消息页字段的常显帮助为:“每次从数据库读取的消息数;会持续读完所选范围,不限制回顾总消息数,也不决定每次发给模型的消息数。”模型批次消息数、片段数和新增字符数分别标注,避免统称“批量大小”。
|
||||
普通配置常显消息每页条数、监督并发、报告/整理模型超时和单次响应容量。高级配置按“来源与分块”“上下文与输出”“汇总与内存”“超时与续跑”“上游额度”分组。消息页字段的常显帮助为:“每次从数据库读取的消息数;会持续读完所选范围,不限制回顾总消息数,也不决定每次发给模型的消息数。”模型批次消息数、片段数和新增字符数分别标注,避免统称“批量大小”。
|
||||
|
||||
字段显示单位、已确定的默认值和合法范围;补充帮助说明调大后的内存、等待或模型用量影响。允许继承的节点超时显示“继承监督整理值”及解析后的秒数;依赖模型容量的输入/输出预算显示所用模型、容量及 token 是否为估算。跨字段校验覆盖输入与输出预留之和、扇入可容纳性、软预算与硬期限顺序、退避起始与上限;错误附在字段并关联可访问说明,提交时聚焦首个错误,Main 仍作权威校验。
|
||||
字段显示单位、已确定的默认值和合法范围;补充帮助说明调大后的内存、等待或模型用量影响。允许继承的节点超时显示“继承监督整理值”及解析后的秒数;依赖模型容量的输入/输出预算显示所用模型、容量及 token 是否为估算。跨字段校验覆盖输入与输出预留之和、扇入可容纳性、退避起始与上限;错误附在字段并关联可访问说明,提交时聚焦首个错误,Main 仍作权威校验。
|
||||
|
||||
规划中的“保存配置”持久化后只用于新建运行,常显“已开始和待继续的回顾沿用启动时配置”。“恢复本组默认值”只修改该组草稿,经保存才生效,不开始回顾、不修改已有计划;取消编辑恢复已保存值。运行详情展示本次实际配置,继续操作沿用它;想采用新算法配置时使用“重新回顾”,说明会重新计算。当前已实现字段仍按上文的阶段读取和共享池生效方式工作,整次 run 冻结接线完成后再统一调整提示。
|
||||
|
||||
@@ -99,6 +99,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
隔离视觉验证使用 `tests/support/supervisor-layout-regression.tsx` 渲染生产 React 组件;所有输入标为模拟 fixture,不读取用户数据库或调用模型。`tests/supervisor-layout.electron.test.ts` 启动 Vite 与 Electron,通过 `executeJavaScript` 测量布局、`capturePage` 截图,覆盖 1440、1024、390px、浅深主题、长名称、密集事件和异常状态。可通过 `GOODBUDDY_SUPERVISOR_ARTIFACTS` 保留截图;交互预览入口为 `node tests/support/supervisor-preview.mjs`。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT=1` 验证工作回顾、空态、活动、待处理及失败场景,在 2000、1440、1024、390px 和浅深主题下共 40 个组合,检查全宽、空态居中及位于工具栏下方、阶段连接线和长内容溢出。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_PLAN_MODAL=1` 可只验证五页签 fixture 中的计划 Modal:创建和编辑各覆盖浅深主题、1440×1100、390×1100、390×480,检查关闭按钮、底部按钮对齐、正文滚动、鼠标命中和原生键盘操作。截图及测量写入同一产物目录。
|
||||
|
||||
设置 `GOODBUDDY_SUPERVISOR_RECAP=1` 可验证本次工作回顾分区:1440×1100 和 390×1100 下覆盖监督结果与自动报告均有内容、仅有监督结果、两者均空,另检查历史结果运行中仍可阅读以及活动入口。该分支检查每页一处刷新、自动报告和建议的归属、正文段落与宽度,并截取自动页建议、展开报告和审计区块。
|
||||
|
||||
@@ -467,8 +467,13 @@ Electron cases with substituted model output; live-provider checks remain opt-in
|
||||
migration and backup requirements are defined in the
|
||||
[storage contract](./technical-design.md#file-storage-and-writes).
|
||||
- The startup worker converts one entry per transaction and verifies hydrated
|
||||
readback before clearing the legacy payload. Reclamation checks the freelist
|
||||
on every attempt, including retries after conversion has finished. Committed
|
||||
readback before clearing the legacy payload. The original implementation checked
|
||||
the freelist on every attempt. This was narrowed by the September 21 startup
|
||||
fix and the September 26 structure-upgrade fix: only a required legacy
|
||||
conversion enables reclamation, with its decision retained for same-startup
|
||||
retries, not later launches. Current rules are in the
|
||||
[migration contract](./technical-design.md#schema-38-migration).
|
||||
Committed
|
||||
saves/deletes/resets remain successful when manifest or GC cleanup fails;
|
||||
failures are logged and cleanup is retried on later access or reopen.
|
||||
- Storage implementation handoff reports 135/135 focused tests passed across
|
||||
|
||||
@@ -175,12 +175,16 @@ Already converted entries are reconciled on retry. Matching body revisions do
|
||||
not require rehydrating binary payloads. Direct `AssistantDatabase.initialize`
|
||||
callers and tests also run storage repair.
|
||||
|
||||
Space reclamation is separate from conversion. Every worker attempt checks
|
||||
`PRAGMA freelist_count` and runs checkpoint/VACUUM when free pages remain.
|
||||
Cancellation after the last converted entry or a VACUUM failure therefore
|
||||
allows reclamation to retry on the next startup, even with no legacy rows left.
|
||||
The schema marker alone does not establish that conversion and reclamation
|
||||
finished. General migration requirements are in the
|
||||
Space reclamation is separate from structure upgrades and conversion. Startup
|
||||
detects legacy note payloads and retains the conversion/reclamation decision in
|
||||
memory for that startup's worker retries. Only a required legacy conversion
|
||||
permits checking `PRAGMA freelist_count` and running VACUUM. A structure-only
|
||||
upgrade does not repair notes in the worker or reclaim ordinary free pages.
|
||||
Cancellation after conversion or a VACUUM failure can retry reclamation within
|
||||
the same startup. After quitting, startup checks the schema and remaining legacy
|
||||
payloads again; free pages alone do not trigger another upgrade, and SQLite can
|
||||
reuse them. No additional persistent migration marker is stored.
|
||||
General migration requirements are in the
|
||||
[database migration guide](../../development/database-migrations.md).
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
@@ -7,6 +7,43 @@
|
||||
修正复发缺陷时,优先建立一条能覆盖整类问题的机制(共享选择器、共享函数、能发现全部实例
|
||||
的测试),而不是逐个点名修补。逐个修补正是这些条目反复出现的原因。
|
||||
|
||||
## 普通结构升级再次触发历史回收
|
||||
|
||||
**症状**:历史已经转换完成,后续升级仍显示清理历史并执行耗时的全库回收。
|
||||
|
||||
**原因**:只修复启动检测不够;普通旧 schema 仍会进入升级 Worker,若共享升级方法
|
||||
把任意空闲页当作回收依据,就会在以后每次结构升级时重现。
|
||||
|
||||
**规则**:共享启动决定分别描述内容转换与回收,普通结构升级默认不回收。只有具体
|
||||
旧内容转换允许回收,同次启动重试沿用内存决定;下一次启动重新检测,不增加持久标记。
|
||||
具体规则见[迁移指南](../development/database-migrations.md)。
|
||||
|
||||
**防复发**:schema 45/46 的真实 SQLite fixture 保留正常写入产生的空闲页,断言
|
||||
无历史转换、无 `VACUUM`、内容与完整性保留;真实 Worker 验证取消及重试,
|
||||
启动调度测试分别覆盖允许和禁止回收的决定在重试中不变。
|
||||
|
||||
**历史**:`8dce1d7` 和 `38d0923` 先后修正启动检测,后者随 0.13.9 发布;
|
||||
2026-09-26 检查 0.13.15 的 schema 46→47 路径发现共享 Worker 仍无条件按空闲页回收。
|
||||
本次修复在共享操作层分离结构升级与回收,而非给 46→47 增加特例。
|
||||
|
||||
## 异步界面测试只等待部分状态
|
||||
|
||||
**症状**:本地通过的界面测试在完整 CI 中找不到预期控件,或在前一步更新后立即读取到旧状态。
|
||||
|
||||
**规则**:复用 Testing Library 的 `act`、`waitFor` 和现有 deferred Promise,
|
||||
按请求完成和最终可见状态同步,不以另一个元素出现或消失代替完整操作结束。
|
||||
测试请求重试时显式控制后一次响应,分别断言等待中和响应完成后的状态;
|
||||
同一异步操作的多个最终断言放入同一个 `waitFor`。不要只延长全局超时或放宽断言。
|
||||
Electron 原生拖拽测试应确认窗口焦点、拖拽状态建立、宽度更新及拖拽结束后再断言;
|
||||
移动事件明确携带左键按下状态,不用固定睡眠代替输入处理完成。
|
||||
|
||||
**历史**:2026-09-26 检查 Desktop 0.13.14 标签 CI `36165805814` 与
|
||||
main CI `36164698647`:原生能力重试用例在启动状态未稳定时打开选项,
|
||||
浏览器关闭用例只等待页签消失便同步断言错误已清除。两处测试改用上述已有同步机制;
|
||||
是否消除 CI 失败以修复提交的 Linux CI 结果为准。
|
||||
同日 Windows 全量测试及独立复跑中,Magic Notes 原生拖拽分别停在索引列 168 和
|
||||
AI 列 280。测试同步原生输入与实际状态后独立复跑通过,保留原有宽度和持久化断言。
|
||||
|
||||
## 整窗遮罩与原生拖动区域
|
||||
|
||||
**症状**:弹窗覆盖标题栏区域时,其关闭按钮无法悬停和点击。
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.14",
|
||||
"version": "0.13.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.14",
|
||||
"version": "0.13.16",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.13.14",
|
||||
"version": "0.13.16",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -2,7 +2,39 @@
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.13.14",
|
||||
"version": "0.13.16",
|
||||
"releasedAt": "2026-09-26",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**避免重复整理历史**:普通数据库结构升级不再因为存在空闲页而重新整理历史或执行全库空间回收。启动页会区分结构更新、旧内容转换和实际空间回收,真正需要转换的旧数据仍按原流程处理并保留已有内容。",
|
||||
"**监督回顾持续处理**:回顾较长历史时,不再每隔固定时长自动暂停,减少反复点击继续的操作。仍可主动暂停并从已保存批次继续,单次模型请求的超时和并发设置保持有效。",
|
||||
"**监督页面布局更清楚**:修复工作回顾空态挤在控件旁的问题,运行状态单独显示,减少顶部重复说明。活动记录突出处理阶段、已保存批次和剩余来源,次要统计可展开查看。"
|
||||
],
|
||||
"notices": [
|
||||
"本次仅更新 Desktop,Agent 保持 **0.13.4**,无需为这些修复更新 Host 环境。从 Desktop **0.13.15** 升级不新增数据库格式迁移;从更早版本升级时,仍应备份完整用户数据,包括数据库和相邻 `notes/` 目录。",
|
||||
"监督回顾只读且不调用工具,但会使用已配置模型并可能产生费用。取消固定时长自动暂停后,较长回顾可能连续执行更多批次;可主动暂停,模型结果仍需结合保存的来源核对。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Avoid repeated history cleanup**: Ordinary database structure upgrades no longer reorganize history or reclaim the entire database merely because free pages exist. The startup page distinguishes structure updates, legacy data conversion, and actual space reclamation, while older data that genuinely needs conversion still follows the existing process without losing its contents.",
|
||||
"**Keep Supervisor reviews running**: Reviews of longer histories no longer pause automatically at fixed time intervals, reducing repeated manual continuation. You can still pause and resume from saved batches, and per-request model timeouts and concurrency settings remain effective.",
|
||||
"**Clearer Supervisor layouts**: Fixes the Work review empty state appearing beside its controls, gives run status its own area, and removes repeated introductory text. Activity emphasizes processing stages, saved batches, and remaining sources, with secondary statistics available in expandable details."
|
||||
],
|
||||
"notices": [
|
||||
"This release updates Desktop only. Agent remains at **0.13.4**, with no Host update required for these fixes; upgrading from Desktop **0.13.15** adds no database format migration. When upgrading from older versions, back up complete user data, including the database and adjacent `notes/` directory.",
|
||||
"Supervisor reviews are read-only and cannot call tools, but use the configured model and may incur charges. Without fixed-duration automatic pauses, longer reviews may process more batches continuously; you can pause manually, and results should still be checked against their saved sources."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.13.15",
|
||||
"releasedAt": "2026-09-26",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
@@ -22,7 +54,7 @@
|
||||
"notices": [
|
||||
"首次启动会将数据库升级至 schema 47。升级前请备份完整用户数据,包括数据库及相邻 `notes/` 目录;旧客户端不能打开升级后的数据库,回退需使用升级前备份。",
|
||||
"监督回顾只读且不调用工具,但会调用已配置模型并可能产生费用。分页覆盖不代表模型识别了每个事实,请结合保存的来源核对结果。",
|
||||
"远程用户请先升级 Desktop 至 **0.13.14**,再下载并更新 Host 上的 Agent **0.13.4**。已有旧进程不会热更新,仍在运行或状态不明的旧安装不会被强制删除;Agent 独立发布,不替代桌面端的 Latest 版本。"
|
||||
"远程用户请先升级 Desktop 至 **0.13.15**,再下载并更新 Host 上的 Agent **0.13.4**。已有旧进程不会热更新,仍在运行或状态不明的旧安装不会被强制删除;Agent 独立发布,不替代桌面端的 Latest 版本。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
@@ -42,7 +74,7 @@
|
||||
"notices": [
|
||||
"The first launch upgrades the database to schema 47. Back up complete user data, including the database and adjacent `notes/` directory, before upgrading; older clients cannot open the upgraded database, and rollback requires a pre-upgrade backup.",
|
||||
"Supervision reviews are read-only and cannot call tools, but use the configured model and may incur charges. Paged coverage does not guarantee that the model identifies every fact; check results against their saved sources.",
|
||||
"Remote users should upgrade Desktop to **0.13.14** before downloading and updating Host environments to Agent **0.13.4**. Existing older processes are not hot-updated, and active or uncertain older installations are not forcibly removed; Agent releases remain separate and do not replace the Desktop release marked Latest."
|
||||
"Remote users should upgrade Desktop to **0.13.15** before downloading and updating Host environments to Agent **0.13.4**. Existing older processes are not hot-updated, and active or uncertain older installations are not forcibly removed; Agent releases remain separate and do not replace the Desktop release marked Latest."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EventEmitter } from 'node:events'
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { prepareAssistantStorage } from './assistant-storage-startup'
|
||||
import { getPendingAssistantStorageUpgrade } from './assistant/assistant-storage-upgrade'
|
||||
import { loadMainWindow } from './window'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workers: [] as Array<EventEmitter & { options: { workerData: { upgrade: unknown } } }>,
|
||||
handlers: new Map<string, (event: unknown, input?: unknown) => unknown>(),
|
||||
quit: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: { quit: mocks.quit },
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, input?: unknown) => unknown) => mocks.handlers.set(channel, handler),
|
||||
removeHandler: (channel: string) => mocks.handlers.delete(channel)
|
||||
}
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => {
|
||||
const stat = vi.fn(async () => ({ size: 1024 }))
|
||||
return { stat, default: { stat } }
|
||||
})
|
||||
vi.mock('./window', () => ({ loadMainWindow: vi.fn() }))
|
||||
vi.mock('./assistant/assistant-storage-upgrade', () => ({ getPendingAssistantStorageUpgrade: vi.fn() }))
|
||||
vi.mock('node:worker_threads', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class Worker extends EventEmitter {
|
||||
constructor(_path: string, readonly options: { workerData: { upgrade: unknown } }) {
|
||||
super()
|
||||
mocks.workers.push(this)
|
||||
}
|
||||
}
|
||||
return { Worker, default: { Worker } }
|
||||
})
|
||||
beforeEach(() => {
|
||||
mocks.workers.length = 0
|
||||
mocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([false, true])('retains the original reclaimSpace=%s decision across startup retries', async (reclaimSpace) => {
|
||||
const upgrade = { migrateNotes: reclaimSpace, reclaimSpace }
|
||||
vi.mocked(getPendingAssistantStorageUpgrade).mockReturnValueOnce(upgrade).mockReturnValue(undefined)
|
||||
const webContents = { mainFrame: { url: 'app://main' }, getURL: () => 'app://main' }
|
||||
const window = { webContents, isDestroyed: () => false, setProgressBar: vi.fn() } as unknown as BrowserWindow
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame } as unknown as IpcMainInvokeEvent
|
||||
const preparation = prepareAssistantStorage(window, '/test/assistant.sqlite', new AbortController().signal)
|
||||
await vi.waitFor(() => expect(mocks.workers).toHaveLength(1))
|
||||
mocks.workers[0]!.emit('message', {
|
||||
progress: { stage: 'upgrading', processed: 0, total: 0, bytesBefore: 1024 }
|
||||
})
|
||||
expect(loadMainWindow).toHaveBeenCalledOnce()
|
||||
expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(event)).toMatchObject({ stage: 'upgrading' })
|
||||
mocks.workers[0]!.emit('message', { error: 'Retry this startup' })
|
||||
mocks.workers[0]!.emit('exit', 0)
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.handlers.get(ipcChannels.storageUpgradeProgress)!(event)).toMatchObject({ stage: 'failed' })
|
||||
)
|
||||
mocks.handlers.get(ipcChannels.storageUpgradeAction)!(event, 'retry')
|
||||
await vi.waitFor(() => expect(mocks.workers).toHaveLength(2))
|
||||
expect(getPendingAssistantStorageUpgrade).toHaveBeenCalledOnce()
|
||||
expect(mocks.workers.map(worker => worker.options.workerData.upgrade)).toEqual([upgrade, upgrade])
|
||||
mocks.workers[1]!.emit('message', { done: true })
|
||||
mocks.workers[1]!.emit('exit', 0)
|
||||
await preparation
|
||||
expect(mocks.handlers.size).toBe(0)
|
||||
expect(window.setProgressBar).toHaveBeenLastCalledWith(-1)
|
||||
})
|
||||
|
||||
it('does not open an upgrade page or worker for an already-current database', async () => {
|
||||
vi.mocked(getPendingAssistantStorageUpgrade).mockReturnValue(undefined)
|
||||
await prepareAssistantStorage({} as BrowserWindow, '/test/assistant.sqlite', new AbortController().signal)
|
||||
expect(mocks.workers).toHaveLength(0)
|
||||
expect(loadMainWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../shared/assistant-storage-contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||
import { hasPendingAssistantStorageUpgrade } from './assistant/assistant-storage-upgrade'
|
||||
import { getPendingAssistantStorageUpgrade, type AssistantStorageUpgrade } from './assistant/assistant-storage-upgrade'
|
||||
import { loadMainWindow } from './window'
|
||||
|
||||
export async function prepareAssistantStorage(
|
||||
@@ -23,9 +23,10 @@ export async function prepareAssistantStorage(
|
||||
throw error
|
||||
})
|
||||
if (!file) return
|
||||
if (!hasPendingAssistantStorageUpgrade(databasePath)) return
|
||||
const upgrade = getPendingAssistantStorageUpgrade(databasePath)
|
||||
if (!upgrade) return
|
||||
let progress: AssistantStorageProgress = {
|
||||
stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size
|
||||
stage: 'upgrading', processed: 0, total: 0, bytesBefore: file.size
|
||||
}
|
||||
let shown = false
|
||||
let retry: (() => void) | undefined
|
||||
@@ -53,13 +54,13 @@ export async function prepareAssistantStorage(
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await runStorageWorker(databasePath, signal, publish)
|
||||
await runStorageWorker(databasePath, upgrade, signal, publish)
|
||||
return
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
publish({
|
||||
...progress, stage: 'failed',
|
||||
error: error instanceof Error ? error.message : '历史执行记录优化失败'
|
||||
error: error instanceof Error ? error.message : '本地数据升级失败'
|
||||
})
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = (): void => {
|
||||
@@ -81,6 +82,7 @@ export async function prepareAssistantStorage(
|
||||
|
||||
function runStorageWorker(
|
||||
databasePath: string,
|
||||
upgrade: AssistantStorageUpgrade,
|
||||
signal: AbortSignal,
|
||||
publish: (progress: AssistantStorageProgress) => void
|
||||
): Promise<void> {
|
||||
@@ -93,7 +95,7 @@ function runStorageWorker(
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const worker = new Worker(join(
|
||||
dirname(fileURLToPath(import.meta.url)), 'assistant-storage-worker.js'
|
||||
), { workerData: { databasePath, cancellation } })
|
||||
), { workerData: { databasePath, cancellation, upgrade } })
|
||||
let failure: Error | undefined
|
||||
let done = false
|
||||
worker.on('message', (message: {
|
||||
@@ -107,13 +109,13 @@ function runStorageWorker(
|
||||
if (message.done || message.cancelled) done = true
|
||||
})
|
||||
worker.once('error', () => {
|
||||
failure = new Error('无法启动历史执行记录优化,请重启 GoodBuddy 后重试。')
|
||||
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('历史执行记录优化意外结束,请重试。'))
|
||||
else reject(new Error('本地数据升级意外结束,请重试。'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { upgradeAssistantStorage } from './assistant/assistant-storage-upgrade'
|
||||
import { upgradeAssistantStorage, type AssistantStorageUpgrade } from './assistant/assistant-storage-upgrade'
|
||||
|
||||
const input = workerData as {
|
||||
databasePath: string
|
||||
cancellation: SharedArrayBuffer
|
||||
upgrade: AssistantStorageUpgrade
|
||||
}
|
||||
const cancellation = new Int32Array(input.cancellation)
|
||||
try {
|
||||
// Startup checks for legacy data before spawning this worker, including retries.
|
||||
// Preserve this startup's actual conversion/reclamation decision across retries.
|
||||
upgradeAssistantStorage(
|
||||
input.databasePath,
|
||||
(progress) => parentPort?.postMessage({ progress }),
|
||||
() => Atomics.load(cancellation, 0) !== 0,
|
||||
{ pendingUpgradeConfirmed: true }
|
||||
{ confirmedUpgrade: input.upgrade }
|
||||
)
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
|
||||
it('migrates activity history and writes only changed records during streaming', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-save-io-'))
|
||||
@@ -41,6 +41,7 @@ it('migrates activity history and writes only changed records during streaming',
|
||||
}
|
||||
const beforeBytes = statSync(`${path}-wal`).size
|
||||
database.close()
|
||||
expect(getPendingAssistantStorageUpgrade(path)).toEqual({ migrateNotes: false, reclaimSpace: true })
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
database.initialize(directory)
|
||||
expect(database.getActivityHistory()).toEqual({ records, legacyHistoryMayBeIncomplete: true })
|
||||
|
||||
@@ -1846,21 +1846,24 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
// Called by the existing startup worker before the business connection opens.
|
||||
upgradeMagicNoteStorage(
|
||||
upgradeStorage(
|
||||
onProgress: (progress: AssistantStorageProgress) => void,
|
||||
isCancelled: () => boolean
|
||||
isCancelled: () => boolean,
|
||||
upgrade: { migrateNotes: boolean; reclaimSpace: boolean }
|
||||
): void {
|
||||
const database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
||||
try {
|
||||
database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
|
||||
this.migrate(database, onProgress, isCancelled)
|
||||
this.database = database
|
||||
this.repairMagicNoteStorage(onProgress, isCancelled)
|
||||
if (upgrade.migrateNotes) this.repairMagicNoteStorage(onProgress, isCancelled)
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
const free = database.prepare('PRAGMA freelist_count').get() as { freelist_count: number }
|
||||
if (free.freelist_count > 0) {
|
||||
onProgress({ stage: 'compacting', processed: 0, total: 0, bytesBefore: statSync(this.databasePath).size })
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;')
|
||||
if (upgrade.reclaimSpace) {
|
||||
const free = database.prepare('PRAGMA freelist_count').get() as { freelist_count: number }
|
||||
if (free.freelist_count > 0) {
|
||||
onProgress({ stage: 'compacting', processed: 0, total: 0, bytesBefore: statSync(this.databasePath).size })
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;')
|
||||
}
|
||||
}
|
||||
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
} finally {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
||||
import type { SubagentEvent } from '../../shared/contracts'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
|
||||
import {
|
||||
getPendingAssistantStorageUpgrade,
|
||||
hasPendingAssistantStorageUpgrade,
|
||||
upgradeAssistantStorage
|
||||
} from './assistant-storage-upgrade'
|
||||
@@ -54,6 +55,58 @@ function subagent(requestId: string): SubagentEvent {
|
||||
}
|
||||
|
||||
describe('subagent progress storage', () => {
|
||||
it.each([45, 46])('upgrades schema %i without reconverting history or vacuuming ordinary free pages', async (sourceVersion) => {
|
||||
const { path, database, taskId } = await fixture()
|
||||
const event = subagent(randomUUID())
|
||||
event.progress = [{ id: randomUUID(), type: 'text', content: 'Keep current-format progress' }]
|
||||
database.appendTaskEvent(taskId, 'subagent', event)
|
||||
const note = database.createMagicNote({ title: 'Already migrated', content: { version: 1, ops: [{ insert: 'Keep note\n' }] } })
|
||||
const header = { id: randomUUID(), title: 'Keep chat', updatedAt: 1000 }
|
||||
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
||||
content: 'Normal chat update'.repeat(100_000), createdAt: 1000 }
|
||||
database.saveLocalConversations([{ header, messages: [message] }])
|
||||
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }])
|
||||
const expected = database.getConversation(header.id)
|
||||
database.close()
|
||||
const sql = new DatabaseSync(path)
|
||||
try {
|
||||
sql.exec(`DROP VIEW supervision_review_current;
|
||||
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
|
||||
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;`)
|
||||
if (sourceVersion === 45) sql.exec(`
|
||||
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
||||
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
||||
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;`)
|
||||
sql.exec(`PRAGMA user_version = ${sourceVersion}; PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
||||
const events = sql.prepare('SELECT * FROM task_events WHERE task_id = ? ORDER BY id').all(taskId)
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
|
||||
const confirmedUpgrade = getPendingAssistantStorageUpgrade(path)!
|
||||
expect(confirmedUpgrade).toEqual({ migrateNotes: false, reclaimSpace: false })
|
||||
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
||||
const progress = vi.fn()
|
||||
upgradeAssistantStorage(path, progress)
|
||||
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
||||
expect(progress.mock.calls.map(([value]) => value.stage)).not.toContain('compacting')
|
||||
expect(progress.mock.calls.map(([value]) => value.stage)).not.toContain('converting')
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(sql.prepare('SELECT * FROM task_events WHERE task_id = ? ORDER BY id').all(taskId)).toEqual(events)
|
||||
// A retry in the same startup must not gain reclamation just because free pages remain.
|
||||
upgradeAssistantStorage(path, progress, () => false, { confirmedUpgrade })
|
||||
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
||||
database.initialize(dirname(path))
|
||||
expect(database.getConversation(header.id)).toEqual(expected)
|
||||
expect(database.getMagicNote(note.id)).toEqual(note)
|
||||
database.close()
|
||||
progress.mockClear()
|
||||
upgradeAssistantStorage(path, progress)
|
||||
expect(progress).not.toHaveBeenCalled()
|
||||
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
} finally { sql.close() }
|
||||
})
|
||||
|
||||
it('does not migrate or vacuum ordinary chat free pages on repeated startup', async () => {
|
||||
const { path, database } = await fixture()
|
||||
const header = { id: randomUUID(), title: 'Chat restart', updatedAt: 1000 }
|
||||
|
||||
@@ -12,9 +12,14 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export function hasPendingAssistantStorageUpgrade(
|
||||
export type AssistantStorageUpgrade = {
|
||||
migrateNotes: boolean
|
||||
reclaimSpace: boolean
|
||||
}
|
||||
|
||||
export function getPendingAssistantStorageUpgrade(
|
||||
databasePath: string
|
||||
): boolean {
|
||||
): AssistantStorageUpgrade | undefined {
|
||||
const database = new DatabaseSync(databasePath, {
|
||||
readOnly: true,
|
||||
timeout: 5_000
|
||||
@@ -23,37 +28,50 @@ export function hasPendingAssistantStorageUpgrade(
|
||||
const row = database.prepare('PRAGMA user_version').get() as {
|
||||
user_version: number
|
||||
}
|
||||
if (row.user_version < ASSISTANT_DATABASE_SCHEMA_VERSION) {
|
||||
return true
|
||||
}
|
||||
const noteTable = database.prepare(
|
||||
`SELECT 1
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'magic_note_entries'
|
||||
LIMIT 1`
|
||||
).get()
|
||||
if (!noteTable) return false
|
||||
return Boolean(database.prepare(
|
||||
const migrateNotes = Boolean(noteTable && database.prepare(
|
||||
`SELECT 1
|
||||
FROM magic_note_entries
|
||||
WHERE json_extract(content_json, '$.storage') IS NULL
|
||||
OR json_extract(content_json, '$.storage') <> 'file'
|
||||
LIMIT 1`
|
||||
).get())
|
||||
if (row.user_version >= ASSISTANT_DATABASE_SCHEMA_VERSION && !migrateNotes) return undefined
|
||||
// Reclamation belongs to specific released data conversions, not every schema bump.
|
||||
const legacySubagents = row.user_version > 0 &&
|
||||
row.user_version < SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION &&
|
||||
Boolean(database.prepare("SELECT 1 FROM task_events WHERE kind = 'subagent' LIMIT 1").get())
|
||||
const legacyActivity = row.user_version > 0 && row.user_version < 44 &&
|
||||
Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'activity_history'").get()) &&
|
||||
Boolean(database.prepare("SELECT 1 FROM activity_history WHERE records_json <> '[]' LIMIT 1").get())
|
||||
return { migrateNotes, reclaimSpace: migrateNotes || legacySubagents || legacyActivity }
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPendingAssistantStorageUpgrade(databasePath: string): boolean {
|
||||
return getPendingAssistantStorageUpgrade(databasePath) !== undefined
|
||||
}
|
||||
|
||||
export function upgradeAssistantStorage(
|
||||
databasePath: string,
|
||||
onProgress: (progress: AssistantStorageProgress) => void,
|
||||
isCancelled: () => boolean = () => false,
|
||||
options: { pendingUpgradeConfirmed?: boolean } = {}
|
||||
options: { confirmedUpgrade?: AssistantStorageUpgrade } = {}
|
||||
): void {
|
||||
if (!options.pendingUpgradeConfirmed && !hasPendingAssistantStorageUpgrade(databasePath)) return
|
||||
const upgrade = options.confirmedUpgrade ?? getPendingAssistantStorageUpgrade(databasePath)
|
||||
if (!upgrade) return
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
upgradeSubagentStorage(databasePath, onProgress, isCancelled)
|
||||
new AssistantDatabase(databasePath).upgradeMagicNoteStorage(onProgress, isCancelled)
|
||||
onProgress({ stage: 'upgrading', processed: 0, total: 0, bytesBefore: statSync(databasePath).size })
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
new AssistantDatabase(databasePath).upgradeStorage(onProgress, isCancelled, upgrade)
|
||||
}
|
||||
|
||||
function upgradeSubagentStorage(
|
||||
@@ -137,7 +155,7 @@ function upgradeSubagentStorage(
|
||||
const free = database.prepare('PRAGMA freelist_count').get() as {
|
||||
freelist_count: number
|
||||
}
|
||||
if (free.freelist_count > 0) {
|
||||
if (tasks.length > 0 && free.freelist_count > 0) {
|
||||
progress.stage = 'compacting'
|
||||
onProgress({ ...progress })
|
||||
// Rebuild the now-small live database, not a second copy of the bloated input.
|
||||
|
||||
@@ -86,18 +86,31 @@ it('reopens saved batches after failure, resumes exact Unicode offsets and only
|
||||
expect(f.summarize).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('soft budget pauses without checkpointing and freezes configuration across continuation', async () => {
|
||||
it('continues extraction and navigation beyond the saved execution budget', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1, executionSeconds: 30 })
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(time)
|
||||
f.summarize.mockImplementationOnce(async () => { clock.mockReturnValue(time + 31000); return empty })
|
||||
const result = await f.service().run(request)
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.coverage).toMatchObject({ batches: 3, remainingSources: 0, complete: true })
|
||||
expect(f.summarize).toHaveBeenCalledTimes(5)
|
||||
expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500])
|
||||
clock.mockRestore()
|
||||
})
|
||||
|
||||
it('still pauses on user request and resumes saved batches without changing batch settings', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 })
|
||||
const service = f.service()
|
||||
f.summarize.mockImplementationOnce(async () => empty).mockImplementationOnce(async () => {
|
||||
service.pause(f.db.listSupervisionActivity()[0]!.id)
|
||||
return empty
|
||||
})
|
||||
const result = await service.run(request)
|
||||
expect(result.status).toBe('paused')
|
||||
expect(result.coverage).toMatchObject({ batches: 1, remainingSources: 1, complete: false })
|
||||
f.config.batchCharacters = 16000
|
||||
const resumed = await f.service().resume(result.runId!)
|
||||
expect(resumed.status).toBe('completed')
|
||||
expect((await f.service().resume(result.runId!)).status).toBe('completed')
|
||||
expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500])
|
||||
clock.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects changed source versions and rolls back offsets when batch persistence fails', async () => {
|
||||
|
||||
@@ -264,10 +264,9 @@ export class SupervisorService {
|
||||
this.controllers.set(runId, controller)
|
||||
const activity = { inFlight: 0 }
|
||||
this.activity.set(runId, activity)
|
||||
const deadline = Date.now() + config.executionSeconds * 1000
|
||||
const empty: SupervisionSummaryOutput = { summary: 'No new evidence', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
const paused = () => {
|
||||
db.pause(runId, controller.signal.aborted ? 'Review paused by user' : 'Execution budget reached; continue to review remaining sources')
|
||||
db.pause(runId, 'Review paused by user')
|
||||
return { runId, request, evidence: [], output: empty, status: 'paused' as const, coverage: db.progress(runId) }
|
||||
}
|
||||
try {
|
||||
@@ -300,7 +299,6 @@ export class SupervisorService {
|
||||
if (controller.signal.aborted) return paused()
|
||||
const groups = db.groups(runId, config.concurrency)
|
||||
if (!groups.length) break
|
||||
if (Date.now() >= deadline) return paused()
|
||||
const results = await Promise.allSettled(groups.map(async group => {
|
||||
controller.signal.throwIfAborted()
|
||||
const evidence = db.chunk(runId, group.projectId, group.conversationId, config)
|
||||
@@ -324,7 +322,6 @@ export class SupervisorService {
|
||||
const id = createHash('sha256').update(JSON.stringify([left.id, right.id])).digest('hex')
|
||||
const cached = db.navigation(runId, id)
|
||||
if (cached) return { id, output: cached }
|
||||
if (Date.now() >= deadline) throw new Error('REVIEW_SOFT_BUDGET')
|
||||
const evidence = [left, right].map(card => ({ id: card.id, sourceType: 'note' as const, sourceId: card.id,
|
||||
title: 'Retained review navigation', content: card.output.summary, occurredAt: request.timeRange.to }))
|
||||
const output = await summarize(evidence, true)
|
||||
@@ -378,7 +375,7 @@ export class SupervisorService {
|
||||
await this.store.save(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || (error instanceof Error && error.message === 'REVIEW_SOFT_BUDGET')) return paused()
|
||||
if (controller.signal.aborted) return paused()
|
||||
this.store.fail?.(runId, error instanceof Error ? error.message : 'Review failed')
|
||||
throw error
|
||||
} finally { this.controllers.delete(runId); this.activity.delete(runId) }
|
||||
|
||||
@@ -6,7 +6,7 @@ import { backup, DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MagicNoteCanvasContent, MagicNoteContent } from '../../shared/magic-notes-contracts'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../assistant/assistant-database'
|
||||
import { upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade'
|
||||
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade'
|
||||
|
||||
const actualFs = { renameSync: fs.renameSync, rmSync: fs.rmSync }
|
||||
|
||||
@@ -150,6 +150,7 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
database.close()
|
||||
sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
|
||||
sql.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
const confirmedUpgrade = getPendingAssistantStorageUpgrade(path)!
|
||||
let cancelled = false
|
||||
const exec = DatabaseSync.prototype.exec
|
||||
const injected = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, statement) {
|
||||
@@ -162,9 +163,10 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
injected.mockRestore()
|
||||
expect(sql.prepare("SELECT json_extract(content_json, '$.storage') AS storage FROM magic_note_entries").get()!.storage).toBe('file')
|
||||
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
||||
expect(getPendingAssistantStorageUpgrade(path)).toBeUndefined()
|
||||
const stages: string[] = []
|
||||
upgradeAssistantStorage(path, (progress) => stages.push(progress.stage), () => false, {
|
||||
pendingUpgradeConfirmed: true
|
||||
confirmedUpgrade
|
||||
})
|
||||
expect(stages).toContain('compacting')
|
||||
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBe(0)
|
||||
@@ -257,7 +259,9 @@ describe('Magic note SQLite and filesystem storage', () => {
|
||||
expect(JSON.parse(String(sql.prepare('SELECT content_json FROM magic_note_entries WHERE id = ?').get(entries[0]!.id)!.content_json))).toEqual(rich)
|
||||
sql.exec('DROP TRIGGER fail_note_migration')
|
||||
let cancelled = false
|
||||
expect(() => upgradeAssistantStorage(path, () => { cancelled = true }, () => cancelled)).toThrow('cancelled')
|
||||
expect(() => upgradeAssistantStorage(path, (progress) => {
|
||||
if (progress.stage === 'converting') cancelled = true
|
||||
}, () => cancelled)).toThrow('cancelled')
|
||||
expect(sql.prepare("SELECT COUNT(*) AS count FROM magic_note_entries WHERE json_extract(content_json, '$.storage') = 'file'").get()!.count).toBe(1)
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
database.initialize(directory)
|
||||
|
||||
@@ -9368,6 +9368,7 @@ describe("App", () => {
|
||||
supportsToolExecution: true, detail: "Ready",
|
||||
});
|
||||
const empty = await api.runtimeCustomization.getNativeSnapshot({ provider: "opencode" });
|
||||
const retry = deferred<typeof empty>();
|
||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockClear();
|
||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -9375,18 +9376,26 @@ describe("App", () => {
|
||||
available: false,
|
||||
inventoryStatus: "unavailable",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
.mockReturnValueOnce(retry.promise);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
openComposerOptions();
|
||||
expect(screen.queryByRole("button", { name: /OpenCode Runtime Agent/u })).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
retry.resolve({
|
||||
...empty,
|
||||
available: true,
|
||||
inventoryStatus: "partial",
|
||||
agents: [{ id: "planner", name: "Planner", mode: "primary", native: true, hidden: false }],
|
||||
commands: [{ id: "review", name: "review", source: "command" }],
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
openComposerOptions();
|
||||
fireEvent.click(await screen.findByRole("button", { name: /OpenCode Runtime Agent/u }));
|
||||
await retry.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
||||
expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible();
|
||||
fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Runtime 快捷操作/u }));
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(screen.getByRole('heading', { name: t('supervisor.latest') })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: t('supervisor.run') })).toBeEnabled()
|
||||
expect(screen.getByRole('combobox', { name: t('supervisor.scope') })).toBeVisible()
|
||||
expect(screen.getByText(t('supervisor.sourcesHint'))).toBeVisible()
|
||||
expect(screen.queryByText(t('supervisor.sourcesHint'))).not.toBeInTheDocument()
|
||||
for (const key of ['supervisor.recap', 'supervisor.graph', 'supervisor.automatic', 'activity.title', 'supervisor.settings']) {
|
||||
fireEvent.click(screen.getByRole('tab', { name: t(key) }))
|
||||
const panel = screen.getByRole('tabpanel', { name: t(key) })
|
||||
@@ -224,7 +224,7 @@ describe('HeartbeatCenter', () => {
|
||||
}
|
||||
if (key === 'activity.title') {
|
||||
await screen.findByText(t('activity.empty'))
|
||||
expect(within(panel).getByText(t('activity.description'))).toBeVisible()
|
||||
expect(within(panel).queryByText(t('activity.description'))).not.toBeInTheDocument()
|
||||
expect(panel.querySelector('.scope-badge')).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1795,8 +1795,11 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭浏览器 · conversation-a' }))
|
||||
await waitFor(() => expect(screen.queryByRole('tab', { name: '浏览器 · conversation-a' })).not.toBeInTheDocument())
|
||||
expect(screen.queryByText('Close denied')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(browserApi.closeTab).toHaveBeenCalledTimes(2)
|
||||
expect(screen.queryByRole('tab', { name: '浏览器 · conversation-a' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Close denied')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['create', 'close'] as const)(
|
||||
|
||||
@@ -2,9 +2,40 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { StorageUpgrade } from './StorageUpgrade'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('describes schema-only upgrades without claiming history conversion or reclamation (%s)', async (locale) => {
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
getProgress: vi.fn(async () => ({
|
||||
stage: 'upgrading', processed: 0, total: 0, bytesBefore: 100_000
|
||||
})),
|
||||
act: vi.fn(async () => undefined)
|
||||
} } as unknown as DesktopApi)
|
||||
render(<UiLocaleProvider initialPreference={locale}><StorageUpgrade /></UiLocaleProvider>)
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(locale === 'zh-CN'
|
||||
? '正在更新数据库结构' : 'Applying database structure updates')
|
||||
expect(screen.getByRole('heading')).toHaveTextContent(locale === 'zh-CN'
|
||||
? '正在升级本地数据结构' : 'Upgrading local data structures')
|
||||
expect(screen.queryByText(/清理子任务进度的重复副本|removes repeated copies of subagent progress/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('only describes space reclamation when it is actually running (%s)', async (locale) => {
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
getProgress: vi.fn(async () => ({
|
||||
stage: 'compacting', processed: 0, total: 0, bytesBefore: 100_000
|
||||
})),
|
||||
act: vi.fn(async () => undefined)
|
||||
} } as unknown as DesktopApi)
|
||||
render(<UiLocaleProvider initialPreference={locale}><StorageUpgrade /></UiLocaleProvider>)
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent(locale === 'zh-CN'
|
||||
? '正在回收磁盘空间' : 'Reclaiming disk space'))
|
||||
expect(screen.getByRole('heading')).toHaveTextContent(locale === 'zh-CN'
|
||||
? '正在回收数据库空间' : 'Reclaiming database space')
|
||||
})
|
||||
|
||||
it('shows real progress and lets the user quit without deleting records', async () => {
|
||||
const act = vi.fn(async () => undefined)
|
||||
vi.stubGlobal('goodbuddy', { storageUpgrade: {
|
||||
|
||||
@@ -40,17 +40,24 @@ export function StorageUpgrade(): React.JSX.Element {
|
||||
void poll()
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [])
|
||||
const title = en ? 'Optimizing saved execution history' : '正在优化历史执行记录'
|
||||
const stage = progress?.stage
|
||||
const convertingHistory = stage === 'scanning' || stage === 'converting'
|
||||
const title = stage === 'compacting'
|
||||
? en ? 'Reclaiming database space' : '正在回收数据库空间'
|
||||
: convertingHistory
|
||||
? en ? 'Upgrading saved history' : '正在转换旧版历史数据'
|
||||
: en ? 'Upgrading local data structures' : '正在升级本地数据结构'
|
||||
const status = stage === 'failed'
|
||||
? progress?.error
|
||||
: stage === 'compacting'
|
||||
? en ? 'Reclaiming disk space…' : '正在回收磁盘空间…'
|
||||
: stage === 'complete'
|
||||
? en ? 'Optimization complete. Starting GoodBuddy…' : '优化完成,正在启动 GoodBuddy…'
|
||||
? en ? 'Data upgrade complete. Starting GoodBuddy…' : '数据升级完成,正在启动 GoodBuddy…'
|
||||
: progress?.stage === 'converting'
|
||||
? `${en ? 'Processed' : '已处理'} ${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`
|
||||
: en ? 'Checking saved execution history…' : '正在检查历史执行记录…'
|
||||
: stage === 'scanning'
|
||||
? en ? 'Checking saved history…' : '正在检查旧版历史数据…'
|
||||
: en ? 'Applying database structure updates…' : '正在更新数据库结构…'
|
||||
const act = async (action: 'retry' | 'quit'): Promise<void> => {
|
||||
try {
|
||||
setActionError('')
|
||||
@@ -65,9 +72,17 @@ export function StorageUpgrade(): React.JSX.Element {
|
||||
<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.'
|
||||
: '本次更新清理子任务进度的重复副本,保留聊天、执行详情和结果。处理时间取决于历史数据量和磁盘速度。'}
|
||||
description={convertingHistory
|
||||
? en
|
||||
? 'Converting older stored data to the current format. Your chats, execution details and results are preserved. Time depends on the data size and disk speed.'
|
||||
: '正在将旧版存储数据转换为当前格式,保留聊天、执行详情和结果。处理时间取决于数据量和磁盘速度。'
|
||||
: stage === 'compacting'
|
||||
? en
|
||||
? 'Reclaiming space after a required data conversion. Your chats, execution details and results are preserved.'
|
||||
: '正在回收数据转换后释放的空间,保留聊天、执行详情和结果。'
|
||||
: en
|
||||
? 'Updating the local database structure while preserving your chats, execution details and results. A structure update does not automatically reorganize history or reclaim disk space.'
|
||||
: '正在更新本地数据库结构,保留聊天、执行详情和结果。结构升级不会自动整理历史或回收磁盘空间。'}
|
||||
/>
|
||||
<p role={stage === 'failed' ? 'alert' : 'status'}>{status}</p>
|
||||
{progress?.stage === 'converting' && progress.total > 0 && (
|
||||
@@ -77,8 +92,8 @@ export function StorageUpgrade(): React.JSX.Element {
|
||||
<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>
|
||||
? 'You can quit and continue any remaining required upgrades on the next launch. Committed changes are preserved.'
|
||||
: '可以退出,下次启动时继续尚未完成的必要升级;已提交的变更会保留。'}</p>
|
||||
{actionError && <p role="alert">{actionError}</p>}
|
||||
{stage === 'failed' && (
|
||||
<button className="primary-button" onClick={() => void act('retry')}>
|
||||
|
||||
@@ -11,6 +11,8 @@ it.each(['zh-CN', 'en-US'] as const)('shows actionable supervisor help without d
|
||||
render(<SupervisionReviewSettings />)
|
||||
const copy = i18nResources[locale].heartbeat
|
||||
expect(screen.getByText(copy.reviewSettings.pauseHelp)).toBeVisible()
|
||||
expect(screen.queryByLabelText(new RegExp(copy.reviewSettings.executionSeconds))).not.toBeInTheDocument()
|
||||
expect(copy.reviewSettings.pauseHelp).toContain(locale === 'zh-CN' ? '持续处理至完成' : 'continuously until complete')
|
||||
expect(i18n.exists('reviewSettings.pauseHelp', { ns: 'heartbeat', lng: locale })).toBe(true)
|
||||
expect(JSON.stringify(copy)).not.toMatch(/RPM|TPM|Retry-After|尚未实现|not implemented|没有按时间保存的操作审计|chronological audit log|暂不支持按分钟|Minute intervals are not supported/)
|
||||
expect(copy.timeouts.concurrencyHelp).toContain(locale === 'zh-CN' ? '仅限制监督请求' : 'This limits supervision only')
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../share
|
||||
import { defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
|
||||
const fields = [
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['executionSeconds', 30, 3600], ['responseKiB', 100, 16384]
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384]
|
||||
] as const
|
||||
|
||||
export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
|
||||
@@ -22,8 +22,7 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
|
||||
</li>
|
||||
})}
|
||||
</ol>
|
||||
<p className="supervisor-activity__coverage">{t('reviewSettings.progress', { batches: progress.batches, characters: progress.characters, remaining: progress.remainingSources })}</p>
|
||||
{progress.navigationNodes !== undefined && <p className="supervisor-activity__note">{t('activity.navigationSaved', { count: progress.navigationNodes })}</p>}
|
||||
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
|
||||
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
|
||||
{row.supervisionStatus === 'running' && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and re
|
||||
expect(details.closest('details')).toHaveAttribute('open')
|
||||
expect(details).toHaveStyle({ userSelect: 'text' })
|
||||
expect(screen.getByText(copy.activity.status.failed)).toBeVisible()
|
||||
expect(screen.getByText(copy.activity.description)).toBeVisible()
|
||||
expect(screen.queryByText(copy.activity.description)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: copy.reviewSettings.resume })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -134,12 +134,17 @@ it.each(['summarizing', 'saving', undefined] as const)('renders saved phase %s w
|
||||
settings: { pageSize: 50, batchCharacters: 8000, batchMessages: 20, executionSeconds: 300, timeoutSeconds: 240, concurrency: 1 } }
|
||||
}]), resume } })
|
||||
const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
await screen.findByText(/已保存 6 批/)
|
||||
expect(await screen.findByText('已保存 6 批 · 剩余 0 个来源')).toBeVisible()
|
||||
const detailedProgress = screen.getByText('已保存 6 批 · 已读 4515 个字符 · 剩余 0 个来源')
|
||||
expect(detailedProgress).not.toBeVisible()
|
||||
expect(container.querySelector('[data-phase="saving"]')).toHaveAttribute('data-state', phase === 'saving' ? 'failed' : phase ? 'pending' : 'unknown')
|
||||
expect(container.querySelector('[data-phase="summarizing"]')).toHaveAttribute('data-state', phase === 'saving' ? 'completed' : phase ? 'failed' : 'unknown')
|
||||
expect(container.querySelector('.supervisor-activity__diagnostics')).not.toHaveAttribute('open')
|
||||
expect(container.querySelector('.supervisor-activity__advanced')).not.toHaveAttribute('open')
|
||||
expect(screen.getByText('本次回顾的已保存配置')).not.toBeVisible()
|
||||
fireEvent.click(container.querySelector('.supervisor-activity__advanced > summary')!)
|
||||
expect(detailedProgress).toBeVisible()
|
||||
expect(screen.getByText('本次回顾的已保存配置')).toBeVisible()
|
||||
const action = screen.getByRole('button', { name: phase === 'summarizing' ? '继续合并摘要' : phase === 'saving' ? '重试发布' : '继续已保存回顾' })
|
||||
expect(action.compareDocumentPosition(container.querySelector('.supervisor-activity__diagnostics')!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(action)
|
||||
|
||||
@@ -89,7 +89,6 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<RefreshCw size={14} aria-hidden="true" />{t('center.actions.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="supervisor-activity__note">{t('activity.description')}</p>
|
||||
{error && <div role="alert" className="heartbeat-center__error"><p>{error}</p>
|
||||
<button className="secondary-button" type="button" onClick={() => reload()}>{t('center.actions.retry')}</button>
|
||||
</div>}
|
||||
@@ -102,7 +101,6 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time>
|
||||
</header>
|
||||
<p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p>
|
||||
{row.reviewProgress && <SupervisionReviewStages row={row} />}
|
||||
{!row.reviewProgress && <>
|
||||
<p className="supervisor-activity__stages">
|
||||
{row.heartbeatStatus && <span>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</span>}
|
||||
@@ -123,6 +121,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button>
|
||||
</div>
|
||||
</>}
|
||||
{row.reviewProgress && <SupervisionReviewStages row={row} />}
|
||||
{row.error && <div className="supervisor-activity__failure">
|
||||
<p>{t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error)
|
||||
? 'reviewSettings.identityError' : 'reviewSettings.runError')}</p>
|
||||
@@ -141,6 +140,8 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<div><dt>{t('activity.finished')}</dt><dd>{row.completedAt ? date(row.completedAt) : t('common.unavailable')}</dd></div>
|
||||
{row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>}
|
||||
</dl>
|
||||
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
|
||||
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
|
||||
{row.reviewProgress && row.heartbeatStatus && <p>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</p>}
|
||||
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
|
||||
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
|
||||
|
||||
@@ -449,7 +449,7 @@ export function SupervisorWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(pending === 'run' || pausedReview) && <div className="supervisor-workspace__actions" role="status">
|
||||
{(pending === 'run' || pausedReview) && <div className="supervisor-workspace__run-status" role="status">
|
||||
<span>{t(pausedReview ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
|
||||
{onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>}
|
||||
</div>}
|
||||
@@ -480,7 +480,6 @@ export function SupervisorWorkspace({
|
||||
{tab === 'overview' && (
|
||||
<>
|
||||
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
|
||||
<strong>{t('supervisor.newReview')}</strong>
|
||||
<label>
|
||||
{t('supervisor.scope')}
|
||||
<select
|
||||
@@ -529,9 +528,6 @@ export function SupervisorWorkspace({
|
||||
{t('center.actions.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="supervisor-workspace__muted">
|
||||
{t('supervisor.sourcesHint')}
|
||||
</p>
|
||||
{results.length > 0 && <div className="supervisor-workspace__result-navigation">
|
||||
<label>
|
||||
{t('supervisor.history')}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const heartbeat = {
|
||||
help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.',
|
||||
pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)',
|
||||
batchMessages: 'Messages per batch (default 20)', executionSeconds: 'Time before pausing, seconds (default 300)',
|
||||
pauseHelp: 'When the execution soft budget is reached, active batches finish and save before pausing. Continue from Activity records.',
|
||||
pauseHelp: 'Reviews run continuously until complete and save each batch. Pause and continue from Activity records.',
|
||||
invalid: 'Enter whole numbers within the displayed ranges.',
|
||||
pause: 'Pause review', resume: 'Continue saved review', facts: 'Retained facts and sources',
|
||||
pausedHint: 'Review is incomplete. Successful batches are saved. Open Activity to inspect progress and continue.',
|
||||
@@ -19,7 +19,7 @@ export const heartbeat = {
|
||||
previousPage: 'Previous page', nextPage: 'Next page',
|
||||
extracting: 'Reviewing source batches', summarizing: 'Building navigation summaries', inFlight: '{{count}} queued or running model tasks',
|
||||
configuration: 'Saved review configuration',
|
||||
configurationValues: 'Read {{pageSize}} sources at a time; batch size {{batchCharacters}}, up to {{batchMessages}} messages; pause after {{executionSeconds}}s; model timeout {{timeoutSeconds}}s; up to {{concurrency}} simultaneous tasks. Tasks also share the current supervision concurrency limit.',
|
||||
configurationValues: 'Read {{pageSize}} sources at a time; batch size {{batchCharacters}}, up to {{batchMessages}} messages; run continuously until complete; model timeout {{timeoutSeconds}}s; up to {{concurrency}} simultaneous tasks. Tasks also share the current supervision concurrency limit.',
|
||||
progress: '{{batches}} saved batches · {{characters}} characters reviewed · {{remaining}} sources remaining',
|
||||
identityError: 'The model returned an invalid entity reference. This batch was not saved; previously saved batches remain available.',
|
||||
runError: 'This run failed. Review the error details before continuing; saved batches remain available.',
|
||||
@@ -41,6 +41,7 @@ export const heartbeat = {
|
||||
phases: { collecting: 'Collect sources', extracting: 'Review batches', summarizing: 'Merge summaries', saving: 'Publish review' },
|
||||
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
|
||||
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
|
||||
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
|
||||
navigationSaved: '{{count}} navigation merges saved',
|
||||
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
|
||||
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
|
||||
|
||||
@@ -6,7 +6,7 @@ export const heartbeat = {
|
||||
help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。',
|
||||
pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)',
|
||||
batchMessages: '每批消息数(默认 20)', executionSeconds: '单次整理时长,秒(默认 300)',
|
||||
pauseHelp: '执行时间达到软预算后,正在处理的批次完成并保存,再暂停;可从活动记录继续。',
|
||||
pauseHelp: '回顾持续处理至完成,每批保存进度;可从活动记录主动暂停并继续。',
|
||||
invalid: '请输入所示范围内的整数。',
|
||||
pause: '暂停回顾', resume: '继续已保存回顾', facts: '已保留事实与来源',
|
||||
pausedHint: '本次回顾尚未完成,成功批次已保存。请到活动页查看进度并继续。',
|
||||
@@ -16,7 +16,7 @@ export const heartbeat = {
|
||||
previousPage: '上一页', nextPage: '下一页',
|
||||
extracting: '正在整理来源批次', summarizing: '正在生成导航摘要', inFlight: '{{count}} 个模型任务排队或执行中',
|
||||
configuration: '本次回顾的已保存配置',
|
||||
configurationValues: '每次读取 {{pageSize}} 个来源;每批文本量 {{batchCharacters}},最多 {{batchMessages}} 条消息;整理 {{executionSeconds}} 秒后暂停;模型超时 {{timeoutSeconds}} 秒;最多同时处理 {{concurrency}} 个任务。实际并发还受当前监督模型并发设置限制。',
|
||||
configurationValues: '每次读取 {{pageSize}} 个来源;每批文本量 {{batchCharacters}},最多 {{batchMessages}} 条消息;持续处理至完成;模型超时 {{timeoutSeconds}} 秒;最多同时处理 {{concurrency}} 个任务。实际并发还受当前监督模型并发设置限制。',
|
||||
progress: '已保存 {{batches}} 批 · 已读 {{characters}} 个字符 · 剩余 {{remaining}} 个来源',
|
||||
identityError: '模型返回的实体引用无效,本批未保存,之前成功的批次仍保留。',
|
||||
runError: '本次运行失败,请查看错误详情后继续。已保存的批次仍保留。',
|
||||
@@ -38,6 +38,7 @@ export const heartbeat = {
|
||||
phases: { collecting: '收集来源', extracting: '整理批次', summarizing: '合并摘要', saving: '发布回顾' },
|
||||
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
|
||||
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
|
||||
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
|
||||
navigationSaved: '已保存 {{count}} 次导航合并',
|
||||
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
|
||||
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.supervisor-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
container: supervisor / inline-size;
|
||||
@@ -7,6 +9,23 @@
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.supervisor-workspace > .empty-state {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
}
|
||||
.supervisor-workspace__run-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.supervisor-workspace p,
|
||||
.supervisor-workspace time {
|
||||
overflow-wrap: anywhere;
|
||||
@@ -607,9 +626,6 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.supervisor-workspace__toolbar > strong {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.supervisor-workspace__recap,
|
||||
.supervisor-workspace__detail,
|
||||
.supervisor-workspace__source,
|
||||
@@ -673,11 +689,16 @@
|
||||
.supervisor-activity__status--failed { color: var(--danger); }
|
||||
.supervisor-activity__item > header time { margin-left: auto; color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.supervisor-activity__scope { color: var(--text-secondary); margin-block: var(--space-2) var(--space-4); }
|
||||
.supervisor-activity__steps { display: flex; flex-wrap: wrap; gap: var(--space-4); padding: 0; margin: 0; list-style: none; }
|
||||
.supervisor-activity__steps li { display: flex; align-items: baseline; gap: var(--space-2); padding-block: var(--space-1); }
|
||||
.supervisor-activity__workflow { margin-top: var(--space-4); }
|
||||
.supervisor-activity__steps { display: flex; gap: var(--space-3); padding: 0; margin: 0; list-style: none; }
|
||||
.supervisor-activity__steps li { display: flex; flex: 1; min-width: 0; align-items: center; gap: var(--space-2); padding-block: var(--space-1); }
|
||||
.supervisor-activity__steps li:not(:last-child)::after { content: ''; flex: 1; min-width: var(--space-3); border-top: 1px solid var(--border-control); }
|
||||
.supervisor-activity__steps li[aria-current='step'] strong { color: var(--accent); font-weight: 600; }
|
||||
.supervisor-activity__steps strong { font-size: var(--font-body); font-weight: 500; }
|
||||
.supervisor-activity__steps small { margin-left: var(--space-2); color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.supervisor-activity__step-number { color: var(--text-muted); }
|
||||
.supervisor-activity__step-number { display: grid; place-items: center; flex: 0 0 24px; height: 24px; border: 1px solid var(--border-control); border-radius: 50%; color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.supervisor-activity__steps [aria-current='step'] .supervisor-activity__step-number { border-color: var(--accent); background: var(--accent-subtle); color: var(--accent); }
|
||||
.supervisor-activity__steps [data-state='completed'] .supervisor-activity__step-number { border-color: var(--success); color: var(--success); }
|
||||
.supervisor-activity__steps [data-state="failed"] small { color: var(--danger); }
|
||||
.supervisor-activity__steps [data-state="running"] small { color: var(--accent); }
|
||||
.supervisor-activity__steps [data-state="completed"] small { color: var(--success); }
|
||||
@@ -699,6 +720,7 @@
|
||||
.supervisor-activity__batch-label { display: inline-block; vertical-align: top; max-width: calc(100% - var(--space-4)); max-height: 3em; line-height: 1.5; overflow: hidden; }
|
||||
@media (max-width: 719px) {
|
||||
.supervisor-activity__steps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
|
||||
.supervisor-activity__steps li:nth-child(2)::after { display: none; }
|
||||
.supervisor-activity__steps small { display: block; margin-left: 0; margin-top: var(--space-1); }
|
||||
.supervisor-activity__item > header time { margin-left: 0; flex-basis: 100%; }
|
||||
.supervisor-activity__conversation, .supervisor-activity__batch { margin-left: var(--space-1); padding-left: var(--space-2); }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const assistantStorageProgressSchema = z.object({
|
||||
stage: z.enum(['scanning', 'converting', 'compacting', 'complete', 'failed']),
|
||||
stage: z.enum(['upgrading', 'scanning', 'converting', 'compacting', 'complete', 'failed']),
|
||||
processed: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
bytesBefore: z.number().nonnegative(),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../src/main/assistant/assistant-database'
|
||||
import { getPendingAssistantStorageUpgrade } from '../src/main/assistant/assistant-storage-upgrade'
|
||||
import { assistantStorageProgressSchema } from '../src/shared/assistant-storage-contracts'
|
||||
|
||||
let directory: string
|
||||
let workerPath: string
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-worker-'))
|
||||
workerPath = join(directory, 'worker.cjs')
|
||||
await build({
|
||||
entryPoints: [resolve('src/main/assistant-storage-worker.ts')],
|
||||
outfile: workerPath, bundle: true, platform: 'node', format: 'cjs', target: 'node24'
|
||||
})
|
||||
})
|
||||
afterAll(async () => {
|
||||
if (directory) await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([false, true])('runs the real startup worker with legacy notes=%s without touching unrelated data', async (legacyNotes) => {
|
||||
const root = await mkdtemp(join(directory, 'case-'))
|
||||
const databasePath = join(root, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize(root)
|
||||
const note = database.createMagicNote({
|
||||
title: 'Worker note', content: { version: 1, ops: [{ insert: 'Keep note\n' }] }
|
||||
})
|
||||
const header = { id: randomUUID(), title: 'Worker chat', updatedAt: 1000 }
|
||||
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
||||
content: 'Normal chat'.repeat(100_000), createdAt: 1000 }
|
||||
database.saveLocalConversations([{ header, messages: [message] }])
|
||||
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Keep response' }] }])
|
||||
const expected = database.getConversation(header.id)
|
||||
database.close()
|
||||
const sql = new DatabaseSync(databasePath)
|
||||
try {
|
||||
sql.exec(`DROP VIEW supervision_review_current;
|
||||
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
|
||||
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
|
||||
PRAGMA user_version = 46;`)
|
||||
if (legacyNotes) sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
|
||||
sql.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
||||
const bytesBefore = (await stat(databasePath)).size
|
||||
const upgrade = getPendingAssistantStorageUpgrade(databasePath)!
|
||||
expect(upgrade).toEqual({ migrateNotes: legacyNotes, reclaimSpace: legacyNotes })
|
||||
const run = async (cancelled: boolean) => {
|
||||
const cancellation = new SharedArrayBuffer(4)
|
||||
if (cancelled) Atomics.store(new Int32Array(cancellation), 0, 1)
|
||||
const worker = new Worker(workerPath, { workerData: { databasePath, cancellation, upgrade } })
|
||||
const messages: Array<{ progress?: unknown; done?: boolean; cancelled?: boolean; error?: string }> = []
|
||||
worker.on('message', message => messages.push(message))
|
||||
try {
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
worker.once('error', reject)
|
||||
worker.once('exit', resolve)
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
return messages
|
||||
} finally { await worker.terminate() }
|
||||
}
|
||||
expect((await run(true)).at(-1)).toMatchObject({ cancelled: true })
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(46)
|
||||
const messages = await run(false)
|
||||
expect(messages.at(-1)).toEqual({ done: true })
|
||||
expect(messages.some(message => message.error)).toBe(false)
|
||||
const stages = messages.flatMap(message => message.progress
|
||||
? [assistantStorageProgressSchema.parse(message.progress).stage] : [])
|
||||
expect(stages).toContain('upgrading')
|
||||
if (legacyNotes) {
|
||||
expect(stages).toContain('converting')
|
||||
expect(stages).toContain('compacting')
|
||||
} else {
|
||||
expect(stages).toEqual(['upgrading'])
|
||||
expect((await stat(databasePath)).size).toBeGreaterThanOrEqual(bytesBefore)
|
||||
}
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(getPendingAssistantStorageUpgrade(databasePath)).toBeUndefined()
|
||||
database.initialize(root)
|
||||
expect(database.getConversation(header.id)).toEqual(expected)
|
||||
expect(database.getMagicNote(note.id)).toEqual(note)
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
} finally {
|
||||
database.close()
|
||||
sql.close()
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -19,7 +19,8 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
'<html><body><script type="module" src="/tests/support/magic-notes-layout-regression.tsx"></script></body></html>'))
|
||||
})
|
||||
} }],
|
||||
server: { host: '127.0.0.1', port: 0, watch: null }
|
||||
server: { host: '127.0.0.1', port: 0, watch: null },
|
||||
optimizeDeps: { entries: ['tests/support/magic-notes-layout-regression.tsx'] }
|
||||
})
|
||||
try {
|
||||
await server.listen()
|
||||
@@ -31,7 +32,7 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1');
|
||||
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: false, width: 1280, height: 800, useContentSize: true,
|
||||
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
|
||||
const js = code => win.webContents.executeJavaScript(code).catch(error => { throw new Error(code + ': ' + error.message); });
|
||||
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
@@ -47,14 +48,19 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
const click = async selector => { await js('document.querySelector(' + JSON.stringify(selector) + ').click()'); await settle(); };
|
||||
const rect = selector => js('(() => { const r = document.querySelector(' + JSON.stringify(selector) + ').getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })()');
|
||||
const drag = async (selector, delta) => {
|
||||
win.focus(); win.webContents.focus();
|
||||
await wait('document.hasFocus()');
|
||||
const r = await rect(selector); const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + 80);
|
||||
const target = 'document.querySelector(' + JSON.stringify(selector) + ')';
|
||||
const before = await js(target + '.getAttribute("aria-valuenow")');
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x, y });
|
||||
await settle();
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
||||
await settle();
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: x + delta, y, button: 'left' });
|
||||
await settle();
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
await wait('!!document.querySelector(".magic-notes-layout--resizing, .magic-todo-workspace--resizing")');
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: x + delta, y, button: 'left', modifiers: ['leftButtonDown'] });
|
||||
await wait(target + '.getAttribute("aria-valuenow") !== ' + JSON.stringify(before));
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: x + delta, y, button: 'left', clickCount: 1 });
|
||||
await wait('!document.querySelector(".magic-notes-layout--resizing, .magic-todo-workspace--resizing")');
|
||||
await settle();
|
||||
};
|
||||
const key = async (selector, keyCode) => {
|
||||
@@ -165,10 +171,16 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
let output = ''
|
||||
child.stdout.on('data', data => { output += data })
|
||||
child.stderr.on('data', data => { output += data })
|
||||
// Hidden Electron windows can run rAF at 1 Hz; the measured scenario takes 63s.
|
||||
const timeout = setTimeout(() => child.kill(), 90000)
|
||||
// Native input and animation frames need a visible window, including under Xvfb.
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => { timedOut = true; child.kill() }, 90000)
|
||||
try {
|
||||
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
|
||||
const { code, signal } = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once('close', (code, signal) => resolve({ code, signal }))
|
||||
child.once('error', reject)
|
||||
})
|
||||
expect(timedOut, `Electron layout exceeded 90s (exit=${code}, signal=${signal}).\n${output}`).toBe(false)
|
||||
expect(signal, output).toBeNull()
|
||||
expect(code, output).toBe(0)
|
||||
} finally { clearTimeout(timeout) }
|
||||
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
|
||||
|
||||
@@ -63,8 +63,8 @@ app
|
||||
const reports = []
|
||||
if (process.env.GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT) {
|
||||
win.show()
|
||||
for (const view of ['recap', 'activity', 'pending', 'failure']) {
|
||||
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (view === 'activity' ? '?activity=1' : '?recap=1&long-summary=1' + (view === 'failure' ? '&fail-run=1' : '')))
|
||||
for (const view of ['recap', 'empty', 'activity', 'pending', 'failure']) {
|
||||
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (view === 'activity' ? '?activity=1' : '?recap=1&long-summary=1' + (view === 'failure' ? '&fail-run=1' : view === 'empty' ? '&state=empty' : '')))
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
if (view === 'activity') {
|
||||
await js('document.querySelector("#supervisor-tab-activity").click()')
|
||||
@@ -81,17 +81,23 @@ app
|
||||
await js('document.querySelector(".page-shell").scrollTop = 0')
|
||||
await settle()
|
||||
const report = await js(`(() => {
|
||||
const box = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r && { left:r.left, right:r.right, width:r.width, height:r.height }; };
|
||||
const box = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r && { left:r.left, right:r.right, width:r.width, height:r.height, top:r.top, bottom:r.bottom }; };
|
||||
return { width:innerWidth, pageWidth:document.documentElement.scrollWidth,
|
||||
panel:box('.heartbeat-center > [role=tabpanel]:not([hidden])'), toolbar:box('.supervisor-workspace__toolbar'), history:box('.supervisor-workspace__result-navigation'), recap:box('.supervisor-workspace__recap'),
|
||||
prose:box('.supervisor-workspace__prose'), activity:box('.supervisor-activity'),
|
||||
steps:box('.supervisor-activity__steps'),
|
||||
empty:box('.supervisor-workspace > .empty-state'), emptyTitle:box('.supervisor-workspace > .empty-state strong'),
|
||||
stageConnectors:[...document.querySelectorAll('.supervisor-activity__steps li:not(:last-child)')].map(e=>getComputedStyle(e,'::after').borderTopWidth),
|
||||
summaryLength:[...document.querySelectorAll('.supervisor-workspace__summary')].map(e=>e.textContent).join('').length,
|
||||
tail:document.querySelector('.supervisor-workspace__prose')?.textContent.includes('长摘要末尾'),
|
||||
controls:[...document.querySelectorAll('.supervisor-activity > .supervisor-workspace__action-bar > *')].map(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top};}) };
|
||||
})()`)
|
||||
assert(report.pageWidth <= width, 'No page overflow')
|
||||
if (view !== 'activity') {
|
||||
if (view === 'empty') {
|
||||
assert(Math.abs(report.empty.width - report.panel.width) < 1, 'Empty state fills panel');
|
||||
assert(Math.abs((report.emptyTitle.left + report.emptyTitle.right) / 2 - (report.panel.left + report.panel.right) / 2) < 1, 'Empty state title centered');
|
||||
assert(report.empty.top >= report.toolbar.bottom, 'Empty state below toolbar');
|
||||
} else if (view !== 'activity') {
|
||||
assert(report.summaryLength > 2000 && report.tail, 'Long summary retained')
|
||||
assert(Math.abs(report.recap.width - report.panel.width) < 1, 'Recap fills the page panel')
|
||||
for (const control of [report.toolbar, report.history]) {
|
||||
@@ -102,6 +108,7 @@ app
|
||||
assert(Math.abs(report.activity.width - report.panel.width) < 1, 'Activity fills the page panel')
|
||||
if (width >= 1024) {
|
||||
assert(report.steps.height < 40, 'Compact stage row')
|
||||
assert(report.stageConnectors.every(value => value === '1px'), 'Connected stages')
|
||||
assert(report.controls[2].left - report.controls[1].right <= 24, 'Refresh grouped with filter')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user