Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eaaa85d4c | ||
|
|
36e671a0a2 | ||
|
|
2be57492e6 | ||
|
|
b90ff50f2a | ||
|
|
e50ca6d149 | ||
|
|
b4df493be7 | ||
|
|
bbf24c282a | ||
|
|
126dbc57e2 | ||
|
|
d0877d9212 | ||
|
|
cf598121b1 |
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Prepare sandboxed Electron tests
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends --yes xvfb xauth
|
||||
sudo apt-get install --no-install-recommends --yes xvfb xauth dbus-x11 gnome-keyring
|
||||
sandbox="node_modules/electron/dist/chrome-sandbox"
|
||||
if [[ ! -f "$sandbox" ]]; then
|
||||
rm -rf node_modules/electron/dist
|
||||
@@ -55,7 +55,12 @@ jobs:
|
||||
|
||||
- name: Run validators
|
||||
run: |
|
||||
xvfb-run --auto-servernum npm test
|
||||
dbus-run-session -- bash -euo pipefail -c '
|
||||
export XDG_CURRENT_DESKTOP=GNOME
|
||||
# An isolated CI login keyring exercises real safeStorage encryption.
|
||||
head -c 32 /dev/urandom | base64 | gnome-keyring-daemon --unlock --components=secrets
|
||||
xvfb-run --auto-servernum npm test
|
||||
'
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
|
||||
|
||||
@@ -140,6 +140,14 @@ additional hardening beyond the actual internal-network threat model:
|
||||
|
||||
## Implementation Conventions
|
||||
|
||||
- Read and follow
|
||||
[`docs/architecture/performance-principles.md`](./docs/architecture/performance-principles.md)
|
||||
before designing, implementing, or reviewing a feature. Treat its performance
|
||||
budgets, process boundaries, state ownership, startup rules, and review
|
||||
checklist as implementation constraints. Include this reference in parallel
|
||||
agent assignments so all agents follow the same rules. For performance or
|
||||
hot-path changes, provide the applicable measurements and validation required
|
||||
by that document; do not claim improvements without evidence.
|
||||
- Follow surrounding TypeScript and React patterns.
|
||||
- Reuse installed libraries and shared contracts before adding dependencies.
|
||||
- Keep changes focused. Do not add unrelated refactors or documentation.
|
||||
|
||||
+19
-5
@@ -344,7 +344,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.15.4`, paired with the current Desktop release candidate `0.15.11`; formal
|
||||
`0.15.5`, paired with the current Desktop release candidate `0.15.12`; 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
|
||||
@@ -931,12 +931,13 @@ records are listed separately and do not introduce another feature status.
|
||||
action errors.
|
||||
- [x] **Client-computer control tools**: Managed separately from the built-in
|
||||
browser with scope, cancellation, timeout, output, and activity boundaries.
|
||||
- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom, and
|
||||
DingTalk each have a system-managed project, independent remote
|
||||
- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom,
|
||||
DingTalk, and Telegram each have a system-managed project, independent remote
|
||||
conversations, working directory, processing backend, and
|
||||
task/activity ownership. Each channel controls complete-response
|
||||
length and segmentation according to platform capabilities rather than
|
||||
relying on a shared service truncation.
|
||||
relying on a shared service truncation. Telegram's live Bot + model chain
|
||||
remains [pending acceptance](./docs/features/telegram-channel/progress.md).
|
||||
- [x] **WeChat ClawBot QR login and media**: A separate Sidecar handles local
|
||||
QR scanning, verification codes, encrypted credentials, and text messaging.
|
||||
Personal WeChat chats support images and files, with at most four
|
||||
@@ -982,9 +983,22 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.11`; Agent candidate `0.15.4` requires Desktop `0.15.11`, with
|
||||
- The current Desktop candidate is `0.15.12`; Agent candidate `0.15.5` requires Desktop `0.15.12`, 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.15.12` / Agent `0.15.5` add Telegram private-channel configuration
|
||||
(complete real-bot-plus-model acceptance remains pending) and custom local-date
|
||||
review ranges. Supervisor uses lazy graph/source reads, indexed Worker queries,
|
||||
cached digests and windowed selectors; final publication can still block Main.
|
||||
Unpublished reviews omit deleted conversations and recompute affected batches,
|
||||
retaining published history. Transient model failures allow one extra attempt
|
||||
with possible additional cost. Remote startup/cancellation, channel delivery,
|
||||
long-message disclosure state and missing historical timing are corrected.
|
||||
Schema 62 adds channel support and lookup indexes; back up the complete data
|
||||
directory and restore it to downgrade. Development audits recorded 15 external
|
||||
text requests across two separate stages; this release preparation adds none.
|
||||
Exact-source CI and native packaging remain required. See the
|
||||
[preparation record](./docs/development/release-preparation-0.15.12.md).
|
||||
- Desktop `0.15.11` / Agent `0.15.4` remove Ask/Execute and general tool
|
||||
approvals, including the former read-only boundary of old Ask conversations.
|
||||
Schema 60 and Runtime settings 22 remove retired metadata; back up the complete
|
||||
|
||||
+4
-3
@@ -53,7 +53,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数按当前账号权限发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并应用多文件补丁;读取和补丁 API 保留工作区相对路径合同。用户无需另行安装 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,不要求开机服务。请求使用所选 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.15.4`,配套 Desktop 候选为 `0.15.11`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。请求使用所选 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.15.5`,配套 Desktop 候选为 `0.15.12`;无模式源码要求 runtime/acp 6,发布前须重建配套工件。详细运行边界和 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。
|
||||
@@ -144,7 +144,7 @@
|
||||
- [x] **浏览器多页签与按需资源**:同一对话可打开多个共享登录状态、页面与导航独立的浏览器页签,模型请求固定使用开始时绑定的目标。未使用的请求预留不创建浏览器资源,已释放页面可重新打开;保留显式截图,普通操作不再触发无用的自动截图。
|
||||
- [x] **浏览器关闭与刷新**:AI 使用中的页签也可关闭,不取消整个请求;后续显式导航会创建独立替代页。空白页不能刷新,切换或关闭面板后清除旧操作错误。
|
||||
- [x] **客户端电脑控制工具**:与内置浏览器分开管理,并保留范围、取消、超时、输出边界和执行记录。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。
|
||||
- [x] **远程消息通道项目**:微信 ClawBot、企业微信、钉钉和 Telegram 分别拥有系统管理的项目、独立远程会话、工作目录、处理后端及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。Telegram 的真实 Bot 与模型链路仍[待验收](./docs/features/telegram-channel/progress.md)。
|
||||
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
|
||||
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
|
||||
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
|
||||
@@ -159,7 +159,8 @@
|
||||
### 开源、构建与发布
|
||||
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.11`,Agent 候选为 `0.15.4`、最低要求 Desktop `0.15.11`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- 当前 Desktop 候选为 `0.15.12`,Agent 候选为 `0.15.5`、最低要求 Desktop `0.15.12`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.15.12` / Agent `0.15.5` 增加 Telegram 私聊配置(真实 Bot 与模型完整验收仍待完成)和本地日期自定义回顾。监督图谱/来源按需读取、索引 Worker 查询、摘要缓存和选择列表窗口化减少重复工作,最终发布仍可阻塞 Main;未发布回顾省略已删除会话并重算受影响批次,历史结果保留。临时模型失败最多多试一次,可能增加费用;远程启动取消、通道交付、长消息展开状态及历史缺失时长得到修正。schema 62 增加通道支持和查询索引,升级前备份完整数据目录,降级需恢复备份。两个独立开发审计阶段共记录 15 次外部文本请求,本次发布准备没有新增;精确源码 CI 与原生打包仍为门槛。见[发布准备](./docs/development/release-preparation-0.15.12.md)。
|
||||
- Desktop `0.15.11` / Agent `0.15.4` 移除 Ask/Execute 与通用工具审批,旧 Ask 会话也不再有产品级只读边界。schema 60 和 Runtime 设置 22 清理退休元数据,升级前备份完整数据目录,降级需恢复备份;远程需配套 runtime/acp 6 工件。工作区导入、文件时间排序、会话图片发现和远程专家保存随成果页移除一起提供,不删除既有成果。执行时长按运行状态计时、不补算旧历史,OpenCode 缓存率采用完整输入量。回顾历史、响应布局及螺旋实际区间着色保留未归属事件与中性空档;汇总读取和回顾初始化减少限定路径 I/O,不承诺普遍提速。开发证据含无模式执行与专家图片保存共 25 次真实文本请求,不是本次发布准备新调用或图片生成;精确候选 CI、签名包、公开验证和跨版本活动操作恢复与这些检查分开记录。见[发布准备](./docs/development/release-preparation-0.15.11.md)。
|
||||
- Desktop `0.15.10` 统一项目/会话菜单并显示十个最近会话,增加独立监督模型选择及默认开启的会话 Story Graph 开关。活动索引分页与完整汇总、文档 Worker、延迟启动、窄渲染订阅、列表窗口化、终端批次和旧 Runtime 缓存清理减少重复工作,不承诺通用提速比例。schema 58 要求升级前备份数据库及笔记;高频本地写入改用 NORMAL,断电/系统崩溃可能丢失最近部分写入,远程确认、通道投递和任务生命周期仍用 FULL。Agent 保持 0.15.3;四次真实 Host 模型请求及 1000 行 Unicode SSH 终端检查通过,精确候选 CI 与六平台打包仍为发布门槛,不宣称已修复尚未定位的远程恢复错误。见[发布准备](./docs/development/release-preparation-0.15.10.md)。
|
||||
- Desktop `0.15.9` 增加共享回顾进度、故事经验与时间螺旋,schema 56 要求升级前备份,相关模型整理可能增加费用。会话与消息窗口化、Markdown 缓存、只读 Worker、活动差量保存及远程 checkpoint 批次落库减少重复工作,不承诺所有机器相同比例提速;页内查找不覆盖未挂载行,快速滚动可能显示占位,Worker 不可用时同步回退。Execute 配置修改取消重复原生确认,Electron 为 44.5.1。当前源码四次真实 Host 模型请求通过,仍需精确候选 CI 与打包验证,见[发布准备](./docs/development/release-preparation-0.15.9.md)。
|
||||
|
||||
@@ -56,7 +56,7 @@ current limitations.
|
||||
| Data and execution | Local SQLite and note-file storage, current-account tool execution, and recorded tool activity |
|
||||
| Knowledge | File, folder, and web imports with full-text, Chinese phrase, vector, and knowledge graph retrieval |
|
||||
| Desktop releases | Official Windows, macOS, and Linux builds for `x64` and `arm64`, plus an experimental LoongArch preview |
|
||||
| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, and DingTalk |
|
||||
| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, DingTalk, and Telegram ([live Bot + model acceptance pending](./docs/features/telegram-channel/progress.md)) |
|
||||
|
||||
## Product tour
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时
|
||||
| 数据与执行 | 本地 SQLite 与笔记文件存储、当前账号权限下的工具执行、工具调用记录 |
|
||||
| 知识库 | 导入文件、目录和网页,通过全文、中文词组、向量和知识图谱检索 |
|
||||
| 桌面版本 | Windows、macOS、Linux 的 `x64` 与 `arm64` 正式版本,以及龙芯 LoongArch 实验预览版 |
|
||||
| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信和钉钉 |
|
||||
| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信、钉钉和 Telegram([真实 Bot 与模型链路待验收](./docs/features/telegram-channel/progress.md)) |
|
||||
|
||||
## 产品界面
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.15.4",
|
||||
"agentVersion": "0.15.5",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Desktop Storage Direction
|
||||
|
||||
## Status and scope
|
||||
|
||||
2026-10-05: proposed target for the remaining PERF-15/PERF-16 work. The user has
|
||||
requested documentation first, followed by bounded local fixes and a commit.
|
||||
The full storage-service migration remains a separate discussion after those fixes.
|
||||
Current desktop storage still has Main-owned business writes and selected worker
|
||||
reads; this document does not describe a completed migration.
|
||||
|
||||
## Target responsibilities
|
||||
|
||||
- Renderer owns presentation, domain stores and disposable visible-data caches.
|
||||
It consumes summaries, pages and incremental notifications, not full database copies.
|
||||
- Electron Main owns windows, trusted IPC, credentials, OS integration and service
|
||||
lifecycle. Heavy SQL, JSON assembly and document processing execute elsewhere.
|
||||
- A desktop storage utility process would own business database connections and
|
||||
complete domain transactions. Retain the existing database files, schemas and
|
||||
durability distinctions. One write owner per database does not require merging databases.
|
||||
- Existing read-only workers can serve heavy queries under the storage service's
|
||||
lifecycle. Do not place every read and write behind one long-running global job.
|
||||
- Parsing, OCR, model inference and Runtime execution remain separate workloads.
|
||||
Remote Agent databases remain remote; desktop persistence acknowledges remote
|
||||
events only after the required local commit succeeds.
|
||||
|
||||
The service boundary consists of domain operations such as publishing a review or
|
||||
committing an event batch. Do not expose arbitrary SQL, transport transaction
|
||||
callbacks, or split an atomic transaction across individual IPC calls. Synchronous
|
||||
SQLite inside its dedicated execution environment can remain appropriate.
|
||||
|
||||
## Correctness and lifecycle
|
||||
|
||||
Preserve write-before-read ordering, result/checkpoint atomicity, cancellation
|
||||
settlement, event deduplication and commit-before-ACK. A rejected caller Promise
|
||||
does not establish that a writer stopped. Reuse existing operation IDs and unique
|
||||
constraints to resolve uncertain outcomes; do not blindly replay mutations or add
|
||||
a general recovery journal.
|
||||
|
||||
Open business connections after required upgrades. Stop producers before draining
|
||||
required writes and closing storage. A failed storage process must not silently
|
||||
fall back to Main SQL or an empty persistent-data replacement. Keep bounded requests
|
||||
and explicit failure/retry behavior without adding product approval gates.
|
||||
|
||||
## VS Code comparison
|
||||
|
||||
VS Code is a reference for service separation, asynchronous work, lazy activation,
|
||||
cached UI state and shutdown coordination, not a requirement to copy its process map.
|
||||
Its application/profile/workspace state connections are owned by Electron Main;
|
||||
the backend uses asynchronous `@vscode/sqlite3`, while cached reads are synchronous
|
||||
and writes are batched. GoodBuddy's relational history, source bodies and durable
|
||||
remote-event acknowledgements require different payload and persistence boundaries.
|
||||
|
||||
Sources reviewed on 2026-10-05 (upstream main, not a pinned performance comparison):
|
||||
|
||||
- [Connection ownership](https://github.com/microsoft/vscode/blob/main/src/vs/platform/storage/electron-main/storageMain.ts)
|
||||
- [SQLite backend](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/storage/node/storage.ts)
|
||||
- [Cache and flush lifecycle](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/storage/common/storage.ts)
|
||||
- [Extension hosts](https://code.visualstudio.com/api/advanced-topics/extension-host)
|
||||
|
||||
## Current implementation boundary
|
||||
|
||||
The immediate work reuses existing workers, narrows Supervisor loading and payloads,
|
||||
optimizes measured queries, virtualizes lists, and removes repeated digest/snapshot
|
||||
work. It does not add the utility process, convert every storage interface, or claim
|
||||
exclusive ownership of assistant.sqlite. Read-only workers plus remaining Main writes
|
||||
are an explicit transition state.
|
||||
|
||||
Full migration should proceed through verified domain operations and converge by
|
||||
database ownership. Do not leave undocumented competing write paths. Completion
|
||||
requires no Main-owned desktop business SQL, transactions wholly inside the owner,
|
||||
responsive reads/writes under heavy work, and validated startup/cancellation/shutdown.
|
||||
|
||||
The [local implementation record](../review/kiss-local-fixes-2026-10-05.md) owns this
|
||||
round's scope and measurements. The [performance principles](./performance-principles.md)
|
||||
remain the acceptance criteria; regression ceilings are not equivalent to those budgets.
|
||||
@@ -141,3 +141,5 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文
|
||||
| P9 | 性能阈值还没有接入 CI,也没有真实低端机的基线 |
|
||||
|
||||
2026-10-04 补充:监督回顾的来源初始化和继续前版本校验已移入现有 Worker 入口的独立连接,扫描不持有写锁,清单按 200 条提交,生产失败不回退 Main 同步扫描。最终发布事务仍在 Main;实现与实测边界见[监督者进度](../features/conversation-supervision/progress.md#2026-10-04-回顾初始化阻塞修复)。
|
||||
|
||||
2026-10-05 补充:监督者 UI 的概览、图谱、故事读取也已迁到现有只读 Worker;概览按需加载、列表窗口化及摘要缓存见[KISS 局部修复](../review/kiss-local-fixes-2026-10-05.md)。这与工具侧 Story Graph 读取是不同入口。Main 最终发布及部分大响应仍有超预算样本,不能把局部完成视为全项达标。存储服务目标另见[桌面存储方向](./desktop-storage-direction.md),本轮不实施全面迁移。
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Desktop 0.15.12 / Agent 0.15.5 发布准备
|
||||
|
||||
日期:2026-10-05。基线 `v0.15.11`,冻结源码
|
||||
`2be57492e6de2d639597d0fde740a18b917d7dbb`,8 个提交、144 个文件。
|
||||
检查时工作区干净,只纳入已提交实现和发布准备;保留后来出现的并行工作。
|
||||
标准六平台 Desktop、三平台 Agent,不含 LoongArch。
|
||||
|
||||
## 范围
|
||||
|
||||
- Telegram 私聊配置、数字白名单、whoami 设置顺序、系统代理路径、
|
||||
轮询恢复、待发回复、业务错误与跨层 retry_after;通道图片结果合同及钉钉错误修复。
|
||||
- 自定义回顾天数和本地日期,包含结束日、今天截止提交时刻,
|
||||
保留增量及显式重新整理规则。
|
||||
- 回顾项目隔离、暂停后发布校验、失效故事目标拒绝;已删除会话从未发布
|
||||
清单省略、混合批次和依赖导航重算,历史结果不变,已发模型输入不可撤回。
|
||||
- 监督临时错误最多重试一次,认证/配置/取消/超时/容量不重试,
|
||||
保留失败流用量,额外尝试可能增加费用。超容量中止保留已收到用量,
|
||||
正文清空,最多再消费一秒用量事件,仍返回容量错误。
|
||||
- 概览不预载图谱、来源详情按需加载、现有 Worker 重读取、索引与批量查询、
|
||||
摘要缓存、选择列表窗口化及键盘焦点;发布快照只构造一次,仍在原 Main 事务。
|
||||
- 长消息展开状态仅存用户覆盖项,删除来源清理;历史计时缺失不显示为零。
|
||||
- 本地 OpenCode 提交失败及时终止;Agent 原生启动和路由清理不占全局队列,
|
||||
迟到 Session/路由清理,保持取消、peer、回放去重。
|
||||
独立守护进程生产实现改变,必须发 Agent 0.15.5,最低 Desktop 0.15.12;
|
||||
wire 2.0/runtime-acp 6 和 Runtime/Node/Electron 锁不变。
|
||||
|
||||
## 数据与性能边界
|
||||
|
||||
schema 61 定向扩展 projects/conversations 通道 CHECK,事务中校验并恢复
|
||||
writable_schema/defensive 设置,保留行、rowid 和索引;schema 62 仅新增监督索引。
|
||||
升级前关闭应用备份完整数据目录(数据库、相邻 notes、设置),旧客户端回退需恢复备份。
|
||||
没有进行独立存储服务迁移,不把架构方向文档写成已实现。
|
||||
|
||||
已审阅[性能原则](../architecture/performance-principles.md)与
|
||||
[阶段审计](../review/stage-audit-fixes-2026-10-05.md)、
|
||||
[KISS 实测](../review/kiss-local-fixes-2026-10-05.md)。
|
||||
三轮交替测量仅限指定合成库、设备和源码路径。schema 61→62 的 55 表、
|
||||
32947 行含 rowid 哈希一致及完整性检查通过,不代表全部用户库。
|
||||
大批发布仍约 271~303ms、部分大响应和冷模块存在超预算样本,不宣称全部达标。
|
||||
最终删除会话改动另有四文件 95 项回归,未做完整 UI 或大库删除延迟测量。
|
||||
|
||||
## 开发证据与发布验证分开
|
||||
|
||||
- 第一阶段审计:7 次外部文本请求(Host OpenCode 2、Continue 2、
|
||||
本机 OpenCode 1、监督服务 2),0 次图片;6136 测试通过、87 跳过,
|
||||
typecheck/lint 通过。Host 工具、断开恢复、Agent 提升、回放和启动挂起取消
|
||||
经生产路径验证,结束后所属进程清零。
|
||||
- K07/K08:8 次外部文本 HTTP 请求、均 200、0 次图片,含首次预算
|
||||
不足尝试的 2 次;当前源码两 Runtime 的路由清理、peer、取消、工具、
|
||||
恢复及去重通过,自有进程和目录清零。隔离暂存源码全量 6212 通过、
|
||||
87 跳过,typecheck/lint 通过,不含之后的删除会话改动。
|
||||
- 上述两阶段共 15 次外部文本调用属于既有开发记录,本次发布准备新增调用零。
|
||||
- Telegram 内部链路有真实 Electron/IPC/SQLite 与 loopback API/模型证据,
|
||||
真实 Bot 加模型完整收发仍待验证,旧独立 Node API 成功不替代该验收。
|
||||
本次不使用凭据向外部收件人发送消息,不把内部测试当作真实平台通过。
|
||||
- 最终精确候选仍需 CI 全量验证及生产构建;按用户要求不执行本地
|
||||
测试套件、lint、typecheck、生产构建或安装包探针,只校验发布元数据。
|
||||
|
||||
## 发布检查
|
||||
|
||||
同步审阅并更新双语 FEATURES、Desktop 和 Agent 发布说明。
|
||||
推送双远端、等待精确源码验证后创建不可变双版本标签;
|
||||
公开核验六平台 Desktop 20 项资产/12 安装包、macOS 签名公证、双源索引、
|
||||
网站 12 种标准选择,以及 Agent 三平台目录/签名和生产读取器。
|
||||
不下载 Desktop 安装包,不发布已提交宣传稿。
|
||||
|
||||
## 精确候选 CI
|
||||
|
||||
`36e671a0` 的 Desktop CI `37273689682`:6254 通过、57 跳过、1 失败,
|
||||
生产构建未执行;Agent CI `37273689788` 通过。
|
||||
唯一失败为 Telegram Electron 探针启动时真实 safeStorage 不可用。
|
||||
Ubuntu 验证环境增加 gnome-keyring/D-Bus,在独立会话中以随机临时密码解锁
|
||||
CI keyring 后运行 sandboxed Electron 测试;不使用明文回退、不替换加密接口,
|
||||
保留生产配置加密保存断言。密码仅经管道交给临时 keyring,不写源码或日志。
|
||||
@@ -1,5 +1,51 @@
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-10-05:展开状态缓存复查
|
||||
|
||||
独立复查发现,首次 A10 修复在每个折叠项挂载时保存默认值;无引用的消息也会因引用 Hook 留下一条记录。下节首次验收只检查了 DOM 上限,没有覆盖这部分缓存。新增回归在该实现上失败:分批挂载 10,000 个未操作的消息作用域后,预期缓存为 0,实际为 10,000;恢复默认状态后也没有释放条目。
|
||||
|
||||
现改为只保存偏离自动默认值的用户选择,恢复默认时立即删除覆盖项。消息更新后,清理逻辑只检查已保存的覆盖项,借用历史面板已有的消息索引定位来源,清除已删除消息、工具/输入/推理块/子代理/引用及已过期的流式默认值对应的记录。清理不在滚动或渲染期执行。正常的虚拟行卸载、子代理收起继续保留有效选择,行卸载期间完成的推理仍按原规则自动收起。
|
||||
|
||||
本次执行结果:
|
||||
|
||||
- `npm test -- --project unit src/renderer/src/message-expansion.test.tsx src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`:4 个文件、112 项通过。其中新增 4 项覆盖 10,000 个未操作作用域零缓存、用户选择跨卸载保留、恢复默认释放、消息及折叠项删除、未挂载推理完成。
|
||||
- `npm test -- --project unit src/renderer/src/App.test.tsx -t 'keeps each conversation history window|isolates history and task strips'`:2 项通过,346 项未选中。
|
||||
- `npm test -- --project integration tests/message-expansion.electron.test.ts`:三轮交替比较后,最终测量探针调整也单独复跑通过。`npm test -- --project integration tests/frosted-glass.electron.test.ts`:1 项通过,继续覆盖浅深主题、原生点击、跳底、锚点和缓存切换。
|
||||
- `npm run typecheck`、`npm run lint` 通过。该组定向验收后,完整集成测试为 6,136 项通过、0 失败、87 跳过,见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查);本组模型调用 0 次。
|
||||
|
||||
Electron 环境与合成消息结构同下节。此次比较对象为首次 A10 的默认值缓存实现和本次稀疏覆盖实现,不是 `126dbc57`。测试通过 `GB_EXPANSION_DENSE_SOURCE` 加载修改前保留的模块副本,其余生产 Renderer 源码相同。fixture 临时观察首次展开状态写入,捕获该 Map 后恢复原 `Map.set`;产品未增加诊断接口。每个数据量先进行 200 次未操作导航,实际见到 1,397 个不同消息行(一轮旧实现为 1,401),再原生展开 13 条消息的推理、工具结果和输入,返回原行确认恢复,用 Space 收起一项,最后删除消息。
|
||||
|
||||
| 历史消息数 | 旧实现:未操作导航后条目 | 新实现:未操作导航后条目 | 用户操作后:旧/新条目 | 删除消息后:旧/新条目 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 5,600 | 0 | 5,708 / 38 | 5,708 / 0 |
|
||||
| 10,000 | 5,604 | 0 | 5,904 / 38 | 5,904 / 0 |
|
||||
|
||||
以上计数在三轮比较中一致。交互阶段两版均最多采样到 15 个消息行、926 个 DOM 元素,原消息的三个展开项均恢复;A09 迟到来源响应检查也继续通过。计数针对缓存条目,不是浏览器堆内存字节数。
|
||||
|
||||
| 消息数 | 旧实现每轮导航中位数(ms) | 新实现每轮导航中位数(ms) | 旧实现每轮 p95(ms) | 新实现每轮 p95(ms) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 38.8 / 38.8 / 38.4 | 38.8 / 39.1 / 38.5 | 40.4 / 39.6 / 46.9 | 39.2 / 46.7 / 40.1 |
|
||||
| 10,000 | 38.7 / 39.1 / 38.9 | 38.8 / 38.9 / 38.6 | 39.7 / 39.5 / 46.9 | 39.4 / 39.3 / 46.6 |
|
||||
|
||||
导航计时仍为目标聚焦后再等待两个动画帧,每轮 14 个样本;此次先扫过历史,不能与下节首次进入的计时直接比较。三轮比较中,200 次未操作导航的总耗时,旧实现为 4,753.5–5,080.5 ms,新实现为 4,820.3–5,228.8 ms。数据证明默认条目和删除后的条目不再积累,未证明提速;未测堆内存峰值、低端机节流或完整 App/生产 IPC。
|
||||
|
||||
## 2026-10-05:虚拟消息行的展开状态
|
||||
|
||||
聊天历史面板通过 `message-expansion.tsx` 按消息、子代理及折叠项的稳定标识保留展开状态。虚拟行卸载后只保留状态值,不保留 DOM;仍沿用最多 8 个近期交互行及焦点、选区、问题的挂载保护。阅读行为见[聊天阅读状态](./ui-design.md#聊天阅读状态)。本次没有修改 Runtime 执行或消息持久化。
|
||||
|
||||
旧代码上的新增组件回归在返回原消息时失败:预期展开,实际收起。最终三文件 108 项通过,覆盖嵌套工具、父子同名调用隔离、手动收起、行卸载期间流式完成、既有焦点/选区及平滑滚动。命令:`npm test -- --project unit src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`。另有会话存储、同步、流式缓冲、窗口模型和侧栏回归 48 项,以及 App 历史加载、重试、热切换、流式隔离和超过 500 条消息的定向回归 5 项通过。
|
||||
|
||||
真实 Electron 使用当前 `ChatHistoryPane`、样式及 Markdown 渲染,数据为 2,000/10,000 条合成助手消息,每条含正文、推理、工具结果与输入。Windows x64、Electron 44.5.1、1280×800、缩放 1;原生鼠标展开 13 条相距较远的消息,确认原行卸载后再返回,并用原生 Space 操作。基线为临时 worktree 中的 `126dbc57`。三轮交替运行 `npm test -- --project integration tests/message-expansion.electron.test.ts`;基线运行另设 `GB_HISTORY_SOURCE` 指向该 worktree。每轮、每种数据量采样 14 次导航;计时从请求消息定位到目标 article 获得焦点后的两个动画帧,含这两个帧的等待,不等同于纯渲染耗时。
|
||||
|
||||
| 消息数 | 基线每轮中位数(ms) | 修复后每轮中位数(ms) | 基线每轮 p95(ms) | 修复后每轮 p95(ms) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2,000 | 47.0 / 39.2 / 39.3 | 39.4 / 39.0 / 39.1 | 177.6 / 136.4 / 137.4 | 129.7 / 128.8 / 138.7 |
|
||||
| 10,000 | 47.2 / 39.4 / 39.2 | 38.8 / 39.2 / 39.5 | 56.0 / 40.2 / 43.7 | 41.4 / 39.7 / 40.0 |
|
||||
|
||||
两版在两种数据量下的采样最大值均为 15 个消息行、926 个 DOM 元素;基线三轮均丢失原消息的三个展开状态,修复后三轮均恢复。数字只证明本场景的 DOM 数量有界,计时存在波动,不据此宣称提速或整体性能预算达标。该 Electron fixture 替换了数据来源,不是完整 App、生产 IPC 或真实模型验收;模型调用 0 次。
|
||||
|
||||
既有 `npm test -- --project integration tests/frosted-glass.electron.test.ts` 单独复跑通过,覆盖浅深主题、原生点击、跳底、阅读锚点、会话缓存、加载更早消息及 960×540 短窗口。首次与监督者布局测试合并运行在工具的 60 秒期限内未返回结果,不计为通过;`tests/supervisor-layout.electron.test.ts` 随后单独后台运行通过,总耗时 65.26 秒,stderr 为空。最终完整 typecheck、全仓 lint 和修改范围差异检查通过。首次 lint 曾受其他并行修改中的三个 Runtime 错误阻断,后续复跑已通过。未运行完整测试集或低端机节流测量。
|
||||
|
||||
## 2026-10-04:新建本地项目必选目录
|
||||
|
||||
对应 FR-14 的项目创建入口,规则见 [PRD](./prd.md#fr-14-项目活动汇总)及[界面设计](./ui-design.md#10-项目活动汇总)。创建表单已增加中英文必填提示、无障碍关联及空目录提交检查;共享创建 schema 拒绝空白路径,更新 IPC 使用独立 schema,保留历史空目录项目的保存能力。
|
||||
|
||||
@@ -270,6 +270,14 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
导出也适用于消息通道会话。持久化与刷新规则见
|
||||
[会话置顶存储](../assistant-workbar/execution-history-storage.md#会话置顶)。
|
||||
|
||||
### 聊天阅读状态
|
||||
|
||||
在同一历史面板内,滚动到别处再返回时,消息的推理、工具结果、工具输入、引用和子代理详情保留用户的展开或收起选择。子代理收起后重新展开,其内部工具详情也保留选择;不同消息及父子工具分别记忆。状态保留在历史面板中,不依赖把所有已展开的消息留在 DOM 中;面板被缓存淘汰或应用重启后不恢复这些临时阅读选择。
|
||||
|
||||
未操作的折叠项不留下默认状态记录;用户恢复默认选择、消息或对应折叠项被删除后,释放相应记录。正常滚动卸载不视为删除。
|
||||
|
||||
推理仍在流式输出时默认展开、完成时自动收起,流式期间手动收起不因滚动重挂载而重新展开。有焦点、文本选区端点、待回答问题和待审批项的消息继续按既有规则保留挂载;平滑跳到底部及滚动锚定行为不变。
|
||||
|
||||
## 9. 未来插件入口
|
||||
|
||||
FR-15、US-F1/US-F2 的市场 URL、YAML 导入导出及 ShareServer 分发在后续设计中定义。
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-10-05:Runtime 提交失败与启动取消
|
||||
|
||||
本机 OpenCode 提交失败后会结束所属 SSE 等待;远程原生 Session 启动等待期间,
|
||||
取消与其他 binding 的控制请求可继续处理。保留无默认 Prompt 总时限、共享 peer
|
||||
隔离、取消后的会话清理及原有事件顺序。实现规则见
|
||||
[进程复用设计](./runtime-process-reuse-technical-design.md),旧/新回归、当前源码
|
||||
Windows/Linux x64 实测、真实数据检查及本轮 5 次文本模型调用计数统一记录在
|
||||
[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。
|
||||
|
||||
## 2026-10-04:移除成果页,工作区导入与文件时间
|
||||
|
||||
对应 [FR-WF1~FR-WF3、US-WF1~US-WF3](./workspace-files.md)。成果 Tab、目录注册及独立导入
|
||||
|
||||
@@ -17,6 +17,11 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包
|
||||
|
||||
## 运行与失败
|
||||
|
||||
OpenCode 提交请求失败时直接显示该失败,不等待没有被接受的请求产生结束事件。
|
||||
远程 Runtime 在原生 Session 启动期间也可取消;启动等待不阻塞其他会话的状态读取。
|
||||
取消及清理范围仍限于所属请求,完整规则见
|
||||
[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md)。
|
||||
|
||||
- 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。
|
||||
- 前台同一请求的待答问题按公开 `questionId` 去重,按到达顺序保存在消息的
|
||||
`pendingQuestions` 中。每次显示队首表单;有多个待答请求时显示数量。后续提问和重复
|
||||
|
||||
@@ -168,6 +168,11 @@ Session 和 Prompt 路由删除产品模式,按实际能力和请求归属接
|
||||
使用独立的 ACP 取消路径。
|
||||
现有 `snapshot: false` 必须保留。
|
||||
|
||||
提交 Prompt 的 Promise 与 SSE 消费同时运行。提交返回错误、抛错或控制请求超时后,
|
||||
立即中止本请求的 SSE 订阅并保留提交错误;不能等待服务端为未接受的 Prompt 发送
|
||||
`session.idle`。回收仍先 abort 订阅再结束迭代器,随后完成该 Session 的有界取消,
|
||||
不关闭其他会话的订阅或共享 Server。生产运行不增加整个 Prompt 的默认总时限。
|
||||
|
||||
`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner
|
||||
串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局
|
||||
“当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。
|
||||
@@ -282,6 +287,22 @@ Agent 当前 Runtime installation + 兼容配置
|
||||
- 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity
|
||||
变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。
|
||||
|
||||
`runtime/startPrompt` 校验及 owner 登记经过 backend 控制队列,原生握手、Session
|
||||
创建/恢复和模型路由准备在队列外等待。协议接收端也不等待该 RPC 的最终响应才分派
|
||||
后续帧;未完成的响应继续占用已有 request channel,沿用连接通道容量限制及 FIFO
|
||||
写入。其他方法的分派顺序保持不变,Prompt 终态仍经 backend 控制队列提交。
|
||||
同一 binding 的相同启动请求共用 Promise,内容不同的并发启动返回冲突。
|
||||
|
||||
启动等待属于 Session owner,可以被取消、启动 watchdog 或关闭中断。取消后不得
|
||||
发送 Prompt;原生调用迟到返回的 Session 只关闭自身,不能回收仍服务其他 binding
|
||||
的共享进程。原生握手本身仍由进程 owner 共享,一个等待者退出不取消其他等待者。
|
||||
|
||||
模型路由撤销不等待原生 HTTP 响应后才释放 backend 控制队列;broker 关闭、凭据释放
|
||||
及 terminal 提交仍按原顺序执行。启动准备保存原 transport 和取消 signal,安装完成时
|
||||
若已取消,按原 Session/operation 再撤销一次,避免早先清理被迟到安装覆盖。撤销沿用
|
||||
helper 的 operation 匹配检查,不删除同 Session 的其他 operation。HTTP 沿用 10 秒
|
||||
超时,进程退出或 transport dispose 中止未完成请求;无需新的持久状态或协议阶段。
|
||||
|
||||
原生 `chat.message` 在模型请求前设置该 Session 的工具与模型路由,不再按产品模式
|
||||
过滤 Shell、编辑或写入;请求级端点与子会话归属仍独立。
|
||||
OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
监督回顾按来源版本及正文位置增量处理,手动回顾与心跳共享进度,无变化时跳过模型;“重新整理”才重读所选区间。旧的进度、失败重试和删除边界见[自动增量与手动重看](./logic-design.md#自动增量与手动重看)。
|
||||
|
||||
未发布回顾在会话删除后省略相关来源并继续,具体覆盖和历史保留规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [会话监督 PRD](./prd.md) | 监督目标、证据、介入方式和产品验收 |
|
||||
@@ -24,6 +26,7 @@
|
||||
| [回顾分块调度设计](./review-scheduling-design.md) | 第 0 节定义生产分页、批次持久化、续跑、当前设置及剩余边界;其余章节保留改造前基线、目标设计和原型证据 |
|
||||
| [故事线模型改造设计](./storyline-model-design.md) | 全局 › 项目 › 功能 › 子线索的故事层级、故事归属与经验抽取阶段、用户调整及实施顺序(目标设计) |
|
||||
| [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 |
|
||||
| [Renderer 修复与测量](./frontend-progress.md) | K01、K03、K04 的界面回归、源码 Electron 对照及测量限制 |
|
||||
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
|
||||
|
||||
生产入口已接通完整来源分页、叶子持久化、持续处理至完成、活动页主动暂停/继续及批次详情;设置可调页大小、批次正文/消息上限、单次响应容量、两个模型超时和共享并发。实际合同及限制见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。真实 DeepSeek 验证已覆盖一个完整的 27 条消息会话;较早的 129 条消息实验仍未完成。两次实验共 5 次付费请求,详情见进度。原始 AbortError 原因尚未由日志确定。
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Renderer 修复与测量
|
||||
|
||||
最终集成补注:本轮暂存内容在排除并行功能的隔离工作树通过完整验收,6,212 项通过、0 失败、87 跳过;类型检查与 lint 通过。下文保留分组与早期失败记录,最终提交验收以[统一记录](../../review/kiss-local-fixes-2026-10-05.md#最终检查)为准。
|
||||
|
||||
## 2026-10-05:K01、K03、K04
|
||||
|
||||
对应[局部修复清单](../../review/kiss-local-fixes-2026-10-05.md)及[性能原则](../../architecture/performance-principles.md)的 P5、P6、P7、P8、P10。此处只记录 Renderer;Main 查询、Worker、发布事务和数据库验证由[实施进度](./progress.md)记录。
|
||||
|
||||
`SupervisorWorkspace` 首次工作回顾不再请求图谱。进入图谱页时按所选结果读取,内存中只保留当前结果的一份图谱;普通页签切换复用,刷新、历史导航和修改后的刷新清除缓存。图谱错误不阻止阅读回顾,失效请求不能重新填入旧缓存。来源正文继续在点击后读取,未改变 IPC DTO。
|
||||
|
||||
工作回顾与图谱使用同一份故事读取状态,按范围和刷新版本复用。摘要在 workspace 中按故事快照和时间区间计算一次,子组件直接渲染该结果;父子关系索引替代逐故事全量扫描。保留直接子线索聚合、孤立子线索、跨项目关联、时间边界及排序语义。App 只移除了监督者刷新中的成果列表读取,未拆分全局状态或新增领域 store。
|
||||
|
||||
事件、实体、关系列表使用 `useListWindow`,保留选中和焦点行,按实际行高计算占位。新增组件处理跨窗口的方向键、Home/End、Tab/Shift+Tab 和选择,完整记录仍可滚动访问。原生 Electron 回归发现,窗口切换把已渲染行换成估计高度的占位后,末条焦点行会移出视口;共享 hook 已补可见焦点保护,并验证主动滚离焦点行时不会被拉回。该行为也有独立共享 hook 回归。
|
||||
|
||||
## 回归证据
|
||||
|
||||
修改前运行新用例,三项均按预期失败:概览调用了 `graph`;同一故事快照计算摘要两次;600 个事件全部挂载。修改后,监督者、心跳中心、摘要及共享列表相关回归通过。历史结果、迟到图谱和来源响应、图谱失败与重试、实体修改后重新读取、来源正文按需读取均有覆盖。App 集成回归验证首次和缓存路由返回不再重读成果列表。
|
||||
|
||||
最后复查补充了历史结果已删除的回归:显式结果 ID 查询为空时,工作回顾显示可重试错误,重试仍使用原 ID,不退回最新结果,也不请求图谱。该失败分支在修复前已运行并失败,修复后单独验证;不改变下述性能 fixture 的成功结果路径。
|
||||
|
||||
`tests/supervisor-layout.electron.test.ts` 在 Vite 中运行生产组件,新增 `?windowed=1&recap=1` 固定数据,包含各 600 条事件、实体和关系。原生键盘遍历跨越未挂载行,检查首尾可达、焦点可见、选中状态、滚动后视口有内容;随后执行来源读取、回顾与图谱切换、历史切换、实体编辑和 390px 窗口检查。常规 1440、1024、390px、浅深主题及密集图谱布局场景继续通过。
|
||||
|
||||
测试使用真实 Chromium 布局、鼠标及键盘输入和生产 React 组件。bridge 返回固定合成数据,编辑写入测试内存;这不是生产 Main、SQLite、模型调用的端到端证明。完整 App 路由由 jsdom 集成用例覆盖。未运行模型请求、生产构建、打包或已打包应用探针。
|
||||
|
||||
## 源码性能对照
|
||||
|
||||
Windows x64,Intel Core Ultra X7 358H,Electron 44.5.1,不降速。基线取独立临时 worktree 的 `e50ca6d1`,复用现有 Supervisor Electron/Vite fixture;Vite 从 worktree 读取基线 Renderer/shared 源码。两版本使用相同合成数据和原生鼠标输入,最终顺序为基线 1、修复 1、基线 2、修复 2、基线 3、修复 3。每次启动独立 Vite 缓存和 Electron 用户目录。
|
||||
|
||||
下表每格按三轮列值,单位为 ms。点击反馈为点击事件至两次 `requestAnimationFrame` 的代理指标;就绪帧指帧回调中观察到 `aria-busy=false`,不等于完整内容提交或合成器呈现时刻。长任务来自 Renderer `PerformanceObserver`,未与 Main 查询耗时相加。
|
||||
|
||||
| 场景与指标 | 基线三轮 | 修复三轮 |
|
||||
| --- | --- | --- |
|
||||
| 已预载路由,2,000 事件,图谱点击反馈 | 346.3 / 325.0 / 188.3 | 29.9 / 38.5 / 24.3 |
|
||||
| 同场景,图谱长任务时长 | 338+203 / 317+169 / 182+131 | 0 / 0 / 0 |
|
||||
| 同场景,挂载行数 / 文档元素数 | 2,000 / 8,207 | 24 / 329 |
|
||||
| 2,000 故事各十事件,首次回顾摘要 CPU | 80.0 / 73.2 / 48.2 | 15.4 / 16.2 / 9.3 |
|
||||
| 同场景,首次回顾长任务时长 | 83 / 75 / 50 | 0 / 0 / 0 |
|
||||
| 同场景,图谱返回回顾的摘要 CPU | 140.8 / 117.5 / 88.7 | 0 / 0 / 0 |
|
||||
| 同场景,缓存路由返回的摘要 CPU | 67.4 / 74.1 / 42.6 | 0 / 0 / 0 |
|
||||
| 同场景,缓存路由返回点击反馈 | 73.4 / 81.1 / 47.5 | 5.2 / 8.7 / 6.7 |
|
||||
| 注入 250ms 图谱延迟,首次回顾就绪帧 | 282.7 / 265.1 / 263.8 | 16.2 / 10.9 / 10.6 |
|
||||
| 未预载模块,八事件,首次回顾出现帧 | 340.9 / 341.9 / 308.1 | 312.5 / 343.8 / 331.1 |
|
||||
|
||||
修复版本首次回顾的图谱请求数为零;图谱首次打开才承担模拟延迟。普通图谱/回顾切换不再请求故事,缓存路由返回不再计算摘要。摘要首次的计数包含一次空数据计算和一次已加载快照计算,后续同一快照复用。
|
||||
|
||||
未预载模块的首次回顾仍约需 0.31~0.34 秒,包括 Vite 模块加载及 React Suspense 行为,未证明这一项有改善。在该场景随后首次打开八事件图谱时,修复版本第二轮仍出现一次 55ms 长任务;其余修复场景未观察到长任务。三轮样本不足以声明 p95 达标,源码模式也不能代替发布包测量。三个场景均未加载 Three.js,此处没有测量螺旋的 WebGL 初始化。
|
||||
|
||||
原始记录及临时测量脚本保留在本机批准的临时目录 `opencode`:`supervisor-comparison.jsonl`、`supervisor-audit-runner.mjs`、`supervisor-audit-driver.mjs`。最终数据标签为 `baseline-final` 和 `fixed-final`;文件还保留较早的对照轮次,不能混算。首次审查和各轮间波动明显,因此记录逐轮数值,不把模块加载、查询等待和渲染 CPU 合并归因为存储。
|
||||
|
||||
## 验证状态
|
||||
|
||||
- 最终定向 Renderer 与共享列表测试:8 个文件、139 个用例通过。此前包含相关 Main 文件的定向重跑为 368 通过、1 跳过。
|
||||
- App 首次入口、缓存路由返回及侧栏图谱导航:3 通过。
|
||||
- 完整 Supervisor 源码 Electron 布局与交互:通过;`npm run lint` 通过。
|
||||
- 完整 `npm test` 执行结束:515 个测试文件通过、6 个失败、15 个跳过;6,208 个用例通过、7 个失败、87 个跳过,另有一个未处理拒绝。随后修正并重跑了本任务的焦点问题;相关 Main 失败在其负责方更新后重跑通过,Magic Notes 原生布局独立重跑通过。
|
||||
- 当时 `tests/message-expansion.electron.test.ts` 独立重跑仍触发 90 秒超时;后续定位、修复及定向绿色验证见下节。全量测试尚未整体重跑,不能将定向验证记为全量通过。
|
||||
- 最终 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。文案扫描无阻断项;复核候选为加载、错误和测量范围的必要限制说明,已保留。
|
||||
|
||||
## 2026-10-05:消息展开测试复查
|
||||
|
||||
该 Electron 测试在 2,000 和 10,000 条聊天记录场景之后,还执行监督者来源请求的语言切换失效场景。聊天历史使用独立的消息窗口机制,不调用本轮修改的 `useListWindow`;监督者 fixture 只有一个事件,也未进入长列表窗口路径。相同断言下,`e50ca6d1` 和当前源码的普通复跑分别在 16.4 秒和 15.8 秒通过,未复现确定性的 Renderer 性能回退。
|
||||
|
||||
加入有界的 100ms 替换图谱响应后,两版本均重现测试同步错误:语言已切换,旧来源按钮仍短暂存在,测试便继续执行;固定两帧后读取按钮的 `disabled` 时,刷新已卸载该节点,抛出空引用错误。测试现在通过 fixture 的来源标题区分第几次图谱响应,等待第二次响应实际渲染后再完成旧来源请求并检查失效结果。初始语言固定为中文,保证切换到英文确实触发刷新。
|
||||
|
||||
轮询辅助函数另有一个已验证的超时问题:第二次及后续动画帧中的谓词异常没有进入 Promise 的拒绝路径,Promise 会永久等待,直到外层 90 秒看门狗杀死 Electron。批准临时目录中的 `expansion-wait-probe.mjs` 使用两版本的原始辅助函数和同一“节点消失”输入验证:旧版为 `pending` 加未捕获错误,修复版为 `rejected` 且无未捕获错误。已将谓词异常接入拒绝路径,并保留原有 10 秒等待上限;外层 90 秒上限、DOM 数量、展开状态、键盘和旧来源断言均未放宽。
|
||||
|
||||
原先的 90 秒日志没有阶段信息,无法证明它一定由上述某一个等待点触发。此次修复针对已重现的同步错误与未结束的等待 Promise,不将先前超时归因于共享列表或存储。新增阶段耗时及 Renderer 错误输出便于后续定位。没有修改产品 Renderer 或后端代码来绕过测试。
|
||||
|
||||
严格 fixture 下,基线完整测试在 15.6 秒通过。当前源码最终合并运行消息展开、监督者布局和共享列表等 10 个测试文件,141 个用例通过,总耗时 69.7 秒;其中消息展开各阶段合计 14.7 秒。两种聊天规模均访问 1,397 条不同记录,最多挂载 15 行、926 个文档元素;保留的展开项从 38 条在删除后归零,原生 Space 收起、输入草稿、监督者迟到来源失效均通过。Supervisor 继续覆盖原生首尾跳转、跨窗口 Tab、焦点可见、主动滚离焦点、历史、来源、编辑及窄窗口。最终类型检查和 lint 通过。
|
||||
|
||||
## 2026-10-05:独立复核修正
|
||||
|
||||
独立复核发现本轮改动引入了两个问题,均在修复前运行回归确认。
|
||||
|
||||
故事加载的旧 `act()` 会保留发起时的 `load()` 回调。A 范围的重命名尚未结束时切到 B,若 A 修改完成后再调用旧回调,它会先递增共享 generation,导致 B 的在途读取也被丢弃。相同范围换刷新版本时,旧回调还会覆盖新结果。新测试分别重现了 B 保持空数据和刷新后显示旧快照;另一个测试确认旧刷新回调会多发一次读取。
|
||||
|
||||
`SupervisionStories` 现在在提交时记录当前范围与刷新版本,在递增 generation 和发出读取之前核对回调归属;响应也须属于同一归属对象和请求序号。卸载后清除归属。四个 hook 回归覆盖跨范围修改完成、同范围刷新版本变化、保留回调在刷新后及卸载后的调用,以及当前修改正常重读并在页签返回时复用快照。修复前四项中三项失败,修复后全部通过;没有改变修改请求本身或增加全局状态管理。
|
||||
|
||||
列表原先只保留视口附近、选中和焦点行。滚到中间后,从面板按 Tab 会进入第一条已挂载记录;从后续控件按 Shift+Tab 会进入最后一条已挂载记录。原生 Electron 回归实际得到 `event-285` 和 `event-314`,且随后不能按完整列表的边界顺序退出。
|
||||
|
||||
`SupervisorSelectionList` 通过已有 `keepIds` 额外保留逻辑首、末行,继续使用原生 Tab 进入和退出,不增加焦点哨兵或拦截外部控件的键盘事件。新增 Electron 场景先选择中间记录、将列表滚到中部,再从真实前后控件用原生 Tab/Shift+Tab 进入。事件、实体、关系三类均检查首末记录、焦点可见、反向退出至前一控件和正向退出至后一控件;随后继续原有方向键、Home/End、跨窗口 Tab、滚动保留、历史、来源和编辑检查。测试准备阶段在同一次浏览器求值中检查并选择中间行,等待选中提交,避免把尺寸重测期间的临时 DOM 当作稳定状态。
|
||||
|
||||
最终定向运行包括消息展开、完整 Supervisor 布局、故事加载、workspace、心跳中心、摘要和共享列表:11 个文件、145 个用例通过,总耗时 69.3 秒。App 中相关监督者、心跳和图谱导航另有 11 个用例通过;类型检查和 lint 通过。消息展开仍覆盖 2,000/10,000 条记录,最多挂载 15 行、926 个元素,三个展开状态恢复、原生 Space、草稿保留、删除清理及迟到来源失效均通过。
|
||||
|
||||
本次没有运行完整仓库测试、生产构建或模型调用,也没有提交。上文性能表保留其测量时的代码状态;首末行保留会额外挂载至多两行,本次未重跑性能对照,不将旧节点数称为此次修正后的测量结果。原生列表回归中的有界 DOM 断言继续通过。
|
||||
|
||||
## 修改归属
|
||||
|
||||
本次 Renderer 任务的产品文件为 `App.tsx`(仅删除成果刷新)、`SupervisorWorkspace.tsx`、`SupervisorSelectionList.tsx`、`SupervisionStories.tsx`、`SupervisionStoryDigest.tsx`、`supervision-story-digest.ts`、`supervisor-workspace.css`、`use-list-window.ts`,以及对应的 `App.test.tsx`、`HeartbeatCenter.test.tsx`、`SupervisorWorkspace.test.tsx`、`SupervisionStories.test.tsx`、`supervision-story-digest.test.ts`、`use-list-window.test.tsx`。当前 `SupervisorWorkspace.tsx` 相对 `e50ca6d1` 的修改均来自本任务,包括最后的历史结果缺失检查。
|
||||
|
||||
本任务的 Electron 文件为 `tests/supervisor-layout.electron.test.ts`、`tests/message-expansion.electron.test.ts`,以及 `tests/support/` 下的 `supervisor-layout-driver.mjs`、`supervisor-layout-regression.tsx`、`supervisor-selection-driver.mjs`、`message-expansion-driver.mjs`、`message-expansion-regression.tsx`。
|
||||
|
||||
| 文档 | 本任务修改 | 并行修改 |
|
||||
| --- | --- | --- |
|
||||
| `frontend-progress.md` | 全文 | 无已识别的其他修改 |
|
||||
| `README.md` | 文档表中的“Renderer 修复与测量”一行 | 未发布回顾遇到会话删除的说明段落 |
|
||||
| `ui-design.md` | 按需图谱读取、历史和刷新等待规则、列表窗口与键盘规则 | 删除会话后省略来源的活动说明段落 |
|
||||
|
||||
其余 Main、Agent、shared DTO、`SupervisorActivity`、`SupervisionReviewStages`、中英文 heartbeat 文案、监督者技术/主进度/需求/逻辑文档、公共评审与路线图、Telegram 和宣传稿修改均不属于本 Renderer 任务。此归属用于区分本任务的改动,未替其他负责方审定或提交其代码。
|
||||
@@ -10,11 +10,21 @@
|
||||
|
||||
回顾获得执行位置后,读取一次连接配置;同次执行中的批次、输出重试、汇总、故事与经验阶段保持该模型。修改监督者选择、应用默认模型或连接配置,仅影响后续执行。继续暂停或失败的回顾、单独重试故事、生成或重试建议时重新读取当前选择;跨重启不保存模型或凭据快照。显式选择的连接被删除、改为图片协议或缺少所需 API Key 时,执行报错并提示修改设置,不自动换成默认模型。
|
||||
|
||||
对应 FR-S6。监督模型遇到可识别的临时失败时,统一等待 500ms 后重试一次:HTTP 429、500/502/503/504,以及短暂网络故障或响应流中断。取消、模型超时、认证、配置、额度或容量错误直接停止;无法确认可恢复的错误也不重试。失败尝试的部分正文丢弃,已报告用量和已保存批次保留。叶子输出校验后的缩块重试、导航输出重试保持原规则;请求生命周期见[模型阶段超时与调度](./technical-design.md#模型阶段超时与调度)。
|
||||
|
||||
## 整体逻辑架构
|
||||
|
||||
报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。
|
||||
|
||||
完整回顾的生产接线已增加分页来源清单、逐批输出/offset 事务和导航节点。用户主动暂停后显示 `paused`,模型或保存失败显示 `failed`;两者均保留此前成功批次,可继续同一 run。回顾不按累计运行时长自动暂停。存在未读来源、覆盖空洞或未成功导航时不能标 `completed`。手动位置与自动 checkpoint 分开,自动位置只在最终发布事务推进。来源变更目前要求重新回顾,模型配置跨重启固定等差异见[生产接线与剩余边界](./review-scheduling-design.md#0-生产接线与剩余边界)。真实模型验证覆盖 27 条消息的完整会话,较大样本失败单独记录。
|
||||
完整回顾的生产接线已增加分页来源清单、逐批输出/offset 事务和导航节点。用户主动暂停后显示 `paused`,模型或保存失败显示 `failed`;两者均保留此前成功批次,可继续同一 run。回顾不按累计运行时长自动暂停。存在未读来源、覆盖空洞或未成功导航时不能标 `completed`。手动位置与自动 checkpoint 分开,自动位置只在最终发布事务推进。现存来源修改仍要求重新回顾;会话删除按下节规则省略。其他差异见[生产接线与剩余边界](./review-scheduling-design.md#0-生产接线与剩余边界)。真实模型验证覆盖 27 条消息的完整会话,较大样本失败单独记录。
|
||||
|
||||
### 未发布回顾遇到会话删除
|
||||
|
||||
对应 FR-S9、US-S35。会话物理删除后,尚未发布的回顾省略其冻结来源,继续处理其他会话;省略包含消息、消息携带的知识引用及已记录会话归属的任务。归档不等同于删除,现存来源修改、单独消息移除或其他不可用原因仍明确失败。初始化前已删除的会话不进入清单。
|
||||
|
||||
含删除来源的叶子整批丢弃,其父级导航失效。混合批次中的存活来源从本次初始 offset 重做,包含这些来源的其他批次也失效,避免覆盖空洞或重复事实;与之无关的批次及导航保留。不从保存片段恢复或重读已删除会话。
|
||||
|
||||
活动及运行详情显示省略来源数;完成仅表示保留来源已处理并发布,不代表原冻结集合全部覆盖。所有来源均被省略时记录 `no_change`,不生成空报告、不推进这些来源的 checkpoint。删除前已发布的结果、事实和历史片段保留规则不变。已发出的模型请求可能仍返回,但其受影响输出不得发布。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
|
||||
@@ -379,7 +379,7 @@ type SupervisorRecord = {
|
||||
5. 普通会话监督失败不影响主回答;自动化配置的强制监督失败进入明确关注状态。
|
||||
6. 评论和通知不包含完整私人消息,证据点击后才在原对象中查看。
|
||||
7. Renderer 传入的 Evidence ID 必须由 Main 重新验证归属。
|
||||
8. 删除会话时按产品数据保留策略删除或匿名化监督记录。
|
||||
8. 删除会话时省略未发布回顾中的该会话来源,继续其他来源,并显示省略范围;已发布历史结果保留。具体规则见[未发布回顾遇到会话删除](./logic-design.md#未发布回顾遇到会话删除)。
|
||||
9. 用户关闭监督后停止新分析,但保留历史,除非用户明确删除。
|
||||
|
||||
## 19. 性能与预算
|
||||
|
||||
@@ -4,6 +4,87 @@
|
||||
|
||||
当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。
|
||||
|
||||
## 2026-10-05 监督页面读取与发布局部优化
|
||||
|
||||
本节记录 K01 的后端来源投影及 K02、K05、K06,基线为 `e50ca6d1`。生产 IPC 的概览、图谱、故事/经验读取已接入现有只读 Worker,文件数据库失败时不回退 Main 查询。schema 62 只增加概览查询索引;图谱只传来源标题,完整正文仍可按来源 ID 读取。发布只构造一次最终图谱快照,原 Main 事务和原子提交规则保留。接口及顺序规则见[技术设计](./technical-design.md#页面读取与发布2026-10-05)。本节不承担 Renderer 懒加载、摘要和虚拟列表的验证。
|
||||
|
||||
先加回归后改实现:最初 6 个用例在旧代码全部失败,分别暴露来源正文随图谱返回、无关项目仍读取全部经验、缺少 UI Worker 路径及重复重写快照等问题。新增文件最终 8 项通过,包含另一连接提交期间的读快照一致性、跨叶子复用身份时首叶子快照与来源引用保留。原子性用例在 checkpoint 写入处注入失败,确认结果、来源、运行状态和 checkpoint 一起回滚,叶子批次可重试。IPC 回归使用真实文件数据库和在临时目录编译的 Worker 源码,断言三个生产 handler 不调用 Main 同步读取;Worker 终止后请求拒绝,来源详情仍返回完整正文。
|
||||
|
||||
源码测量复用 `build/run-main-perf.cjs` 的 esbuild/Electron-as-Node 方法。基线从独立临时 worktree 读取,修复版从工作树读取,各运行三轮,第二轮反转版本及数据量顺序。Windows x64,Core Ultra X7 358H,Electron 44.5.1/Node 24.21.0,无 CPU 降速;同机还有并行开发活动,保留全部波动。每个版本每轮使用独立合成 SQLite,100 个会话、20 份结果,最新结果持有全部图谱事实;小/中/大样本分别为 2,000/20,000/40,000 条消息和 200/2,000/8,000 个事件及来源,消息与来源正文各 2,048 个 ASCII 字节。最大库约 212 MB。这是压力分布,不代表用户库。
|
||||
|
||||
每个读取入口测首次调用和三个立即重复调用,期间用 `setImmediate` 记录调用方事件循环最大间隔。首次概览包含懒启动 Worker;它不是冷 OS 缓存测量。下表为大样本每轮四次调用中的最大 Main 间隔,单位 ms:
|
||||
|
||||
| 入口 | 基线三轮 | 修复后三轮 |
|
||||
| --- | --- | --- |
|
||||
| 概览 | 389.03/471.16/872.84 | 2.45/2.10/3.17 |
|
||||
| 结果图谱 | 583.03/571.86/646.16 | 12.04/12.29/15.79 |
|
||||
| 故事及经验 | 82.92/77.06/72.28 | 8.56/6.94/19.74 |
|
||||
|
||||
修复后大样本概览的首次完成耗时为 94.01/89.65/117.37 ms,暖调用中位数为 0.74/0.74/1.05 ms。图谱首次完成仍为 453.89/476.75/518.11 ms,暖调用中位数为 573.05/551.27/686.15 ms,小时密度仍需读取消息正文。故事及经验首次完成为 90.99/84.18/95.01 ms;SQL 数量从 1,204 降至 6,但全局返回的 17,201 条 SQL 行和完整成员响应保留,不能据此声称整个故事请求变快。
|
||||
|
||||
大样本图谱 JSON 从 25,742,067 降至 7,143,667 字节;概览保持 7,901 字节,故事响应保持 3,317,180 字节。数值是 UTF-8 JSON 大小,不是 Electron IPC 实际传输字节。以上计时覆盖生产仓储方法、真实 Worker 及线程传输;不含 Renderer 解析/渲染、窗口 IPC 序列化、点击到下一帧,也不把几个探针耗时相加为端到端结果。
|
||||
|
||||
发布另用独立合成库,每批 20 个真实消息来源、20 个事件和 20 个实体,实际执行清单初始化、叶子保存和 `saveSupervisionResult`。本次复测保留并行加入的删除会话校验,测试来源确实存在。测量期间关闭自动 WAL checkpoint,以分离事务本身,未改变产品配置或落盘同步级别。三轮交替结果如下,单位 ms:
|
||||
|
||||
| 批数/来源数 | 基线同步耗时 | 修复后同步耗时 |
|
||||
| --- | --- | --- |
|
||||
| 10/200 | 27.86/28.77/30.96 | 28.60/18.33/24.88 |
|
||||
| 50/1,000 | 194.20/228.55/223.33 | 146.28/151.44/127.86 |
|
||||
| 100/2,000 | 686.15/729.16/729.95 | 271.02/286.14/303.41 |
|
||||
|
||||
100 批的快照 UPDATE 从 101 次降至 1 次,快照 SQL 从 444.31/473.67/463.00 降至 14.85/15.49/14.78 ms;修复后 COMMIT 为 27.40/30.89/33.01 ms,整个事务仍为 271.00/286.11/303.39 ms,事件循环最大间隔为 271.06/313.32/303.73 ms。小批次波动大于收益,不声明稳定提速。所有发布样本的 checkpoint 数都与来源数相等。合并后的叶子保存有一次 21.93 ms 峰值,也超过 16 ms,未修改其校验或放宽断言。
|
||||
|
||||
迁移验证直接复制基线 worktree 生成并已关闭的 schema-61 合成库,再运行生产 `upgradeAssistantStorage`。55 张用户表、32,947 行及 rowid 的逐行哈希在升级前后相同;20 份概览、2,000 条来源标题及完整正文、100 个故事、200 条经验的结果与排序等价,另比对项目及无关项目读取。升级耗时 61.73 ms,文件由 95,526,912 增至 95,711,232 字节;只报告 `upgrading`,没有内容转换或压缩,重复升级不再执行。`integrity_check` 为 `ok`、外键检查为空,基线文件 SHA-256 未变。单元测试另覆盖旧库有空闲页时也不回收。
|
||||
|
||||
最终定向命令包含 `supervision-ui-reads`、`supervision-history`、`supervision-timeline`、`supervision-stories`、`supervision-experiences`、`supervision-review`、`supervision-worker`、`readonly-query-reader`、`assistant-storage-upgrade`、`assistant-database` 和 `ipc` 的测试文件:11 文件、422 项通过、1 项原有跳过。`tests/supervisor-layout.electron.test.ts` 与 `tests/magic-notes-layout.electron.test.ts` 独立复跑,2 项通过。`npm test` 全量曾有 6 项失败、6,209 项通过、87 项跳过:两个新发布 fixture 未保留真实会话、旧 IPC fixture 只模拟一次 429 却要求整个请求失败,以及并行删除和两项 Electron 布局回归。发布 fixture 已改用真实消息;IPC fixture 覆盖重试耗尽后再次执行恢复;上述失败文件的最终复跑均通过。未将这次复跑记成全量重跑通过。
|
||||
|
||||
最终 `npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。类型检查发现新 fixture 的阶段字面量不在合同中,已改为既有 `saving`,该文件 8 项再次通过。两份后端文档的中文审校扫描阻断项为 0;人工复核保留数据范围、性能预算和未测项说明。
|
||||
|
||||
原始脚本、18 组读取样本及迁移报告保留在系统临时 `opencode/supervisor-kiss-comparison-DzVb85`,读取汇总为 `entry-summary.json`,迁移报告为 `migration-integrity.json`。并行删除校验合并后的 18 组发布样本位于其 `publication-current/publication-measurements.json`;此前同目录外层的发布报告只代表合并前源码。
|
||||
|
||||
剩余预算问题:故事读取有一次 19.74 ms 的 Main 间隔,超过 16 ms;大图谱仍要等待数百毫秒的 Worker 查询;首次 Worker 启动也会增加小库概览等待。Main 最终发布仍有约 271~303 ms 的同步阻塞,并未达到性能原则。业务写入迁移、独立存储服务、IPC 实际序列化与并发写锁等待不在本次已验证范围,后续方向见[桌面存储职责](../../architecture/desktop-storage-direction.md)。本轮不迁移全部写入,也不放宽预算。外部模型调用为 0;没有读取用户正文、修改用户库、读取环境文件、生产构建、打包或启动已打包应用,没有提交。
|
||||
|
||||
## 2026-10-05 删除会话后继续未发布回顾
|
||||
|
||||
对应 FR-S9、US-S35。删除会话现在使受影响的未发布批次及依赖导航失效,其他来源继续;混合批次重算、省略覆盖和历史保留规则见[逻辑设计](./logic-design.md#未发布回顾遇到会话删除)。活动和运行详情已增加中英文省略数量提示。原先因已删除会话而标记需要重建的运行,可重新继续;现存来源的修改或归档仍失败。
|
||||
|
||||
验证使用隔离文件 SQLite、真实初始化/恢复 Worker 和可控模型返回,未接触用户数据库:
|
||||
|
||||
- `npx vitest run src/main/assistant/supervision-review.test.ts src/main/assistant/supervision-worker.test.ts src/main/assistant/supervision-production.test.ts src/renderer/src/SupervisorActivity.test.tsx`:4 文件、95 项通过。新增场景包括恢复前删除整会话及知识引用/任务、Worker 复制清单时删除、两路处理及导航期间删除、发布前另一连接删除、混合批次存活来源重做、父导航递归失效、历史结果及来源逐字段不变、全部来源删除无空报告和中英文省略提示。
|
||||
- Node/Web TypeScript 检查、11 个改动 TS/TSX 文件的定向 ESLint、`git diff --check` 通过。差异检查仅有仓库既有 LF/CRLF 提示。
|
||||
- `npx vitest run src/main/assistant/assistant-database.test.ts -t explicitly.deletes.only.local`:既有本地会话删除与消息级联回归通过,其余 124 项按筛选跳过。
|
||||
|
||||
外部模型调用为 0;未运行全量测试、Electron 界面实测或大库删除延迟测量,未提交或推送。回顾仍在桌面直连模型路径执行,不改变远程 Agent。已发出的请求可在删除后返回,结果会被丢弃;不承诺撤回已发送给模型的输入。旧清单若未保存任务会话归属且任务早已被外部删除,不能推断其归属,仍按来源不可用失败。
|
||||
|
||||
## 2026-10-05 监督模型临时失败重试
|
||||
|
||||
对应 FR-S6。`runSupervisionModel` 已接入一次可取消的临时失败重试,覆盖监督者各模型阶段;错误分类、清理及每次尝试的超时规则见[技术设计](./technical-design.md#模型阶段超时与调度)。直连文本请求保留 HTTP status,并交出失败流中已报告的用量。OpenCode/Continue、直连传输重试和叶子/导航输出重试未改。监督者仍在桌面 Main 直连模型,不经过远程 Agent 或模型桥。
|
||||
|
||||
验证结果:
|
||||
|
||||
- `npx vitest run src/main/assistant/supervision-model-retry.test.ts src/main/assistant/supervision-production.test.ts src/main/assistant/supervision-review.test.ts src/main/assistant/supervision-model-pool.test.ts src/main/agent/model-runtime.test.ts`:5 个文件,177 项通过,1 项原有跳过。覆盖 429、可恢复 5xx、部分正文后 terminated、连续失败最多两次包装层调用、认证/配置/容量/超时不重试、等待中取消、流和会话清理、用量保留,以及已保存叶子在失败和继续后不重算。
|
||||
- `npx tsc --noEmit -p tsconfig.node.json`、`npx tsc --noEmit -p tsconfig.agent.json`、4 个改动 TypeScript 文件的定向 ESLint 和 `git diff --check` 通过。
|
||||
|
||||
请求验证使用生产 `ModelAgentRuntime` 和注入的假 fetch/响应流,数据保存使用内存 SQLite;外部模型调用为 0。未运行全量测试、真实模型或远程 Host 验证,未提交或推送。
|
||||
|
||||
## 2026-10-05 后端阶段审查 A03/A04/A05
|
||||
|
||||
对应 SL-5、SL-8、FR-S6、FR-S10。生产候选读取现在将批次项目和跨项目设置传到只读 Worker;最终候选读取返回后检查暂停/取消;故事归属和拆分在原写事务内拒绝已移除或合并的目标。存储、停止与重试规则见[技术设计](./technical-design.md#单次执行与取消)。没有 schema 变更,原始消息、历史结果及人工确认字段保留。
|
||||
|
||||
先加回归、后改实现的结果:8 个新用例中旧代码失败 7 个,取消用例原本通过。失败覆盖跨项目开/关两种生产候选、最终候选读取期间暂停、归属期间移除/合并目标、拆分前移除功能/子线索。旧暂停用例实际返回 `completed` 并发布结果;旧归属用例写入隐藏目标且把事件标为已处理。修复后暂停不发布结果或 checkpoint,继续仍复用原批次;失效故事批次回滚且事件可重试。生产身份测试补充真实项目事件关联,保留 UUID 别名、确认字段及失败后继续的断言。
|
||||
|
||||
验证命令与结果:
|
||||
|
||||
- `npm test -- src/main/assistant/supervision-production.test.ts src/main/assistant/supervision-worker.test.ts src/main/assistant/supervision-stories.test.ts src/main/assistant/supervision-review.test.ts src/main/assistant/supervision-timeline.test.ts src/main/assistant/supervision-experiences.test.ts src/main/assistant/supervisor-service.test.ts src/main/readonly-query-reader.test.ts`:8 个文件、96 项通过。Worker 回归每轮使用两个项目、422 条来源,并检查候选隔离、实体数量、外键及暂停后的完整发布。
|
||||
- 本次修改的 Main 源码、测试和验证脚本定向 ESLint、`git diff --check` 通过。`npm run typecheck` 首轮发现本轮 fixture 缺默认设置字段,修正后曾被并行 `tests/message-expansion.electron.test.ts:39` 类型错误阻塞;该文件由其负责方修正后,Main/Agent/Web 三组最终全部通过。本轮未修改该文件。
|
||||
- 定向验收时 `npm run lint` 曾被并行 OpenCode 修改中的三项错误阻塞;负责方修正后,最终全仓 lint 与 typecheck 均通过。两份后端文档扫描阻断项为 0;人工复核保留取消与事务顺序的边界说明。完整集成结果见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。
|
||||
- `node --import jiti/register scripts/supervision-audit-validation.ts scale <临时父目录>`:真实文件 SQLite,10 个项目、1,000 个实体、10,000 个事件、10,000 条事件实体关联、10 条来源,库文件 10,964,992 字节。三轮交替查询中,每轮十次旧相关子查询合计 3,517/5,302/4,190 ms;新 Worker 查询合计 39.18/34.01/36.04 ms,包含传输。逐项目返回的 100 个候选 ID 及顺序与旧 SQL 完全一致;跨项目读取每轮 0.95~1.79 ms。120 个事件的单批归属事务为 6.63 ms。计数不变,`foreign_key_check` 为空,`integrity_check` 为 `ok`。
|
||||
- 查询测量是同一合成库内的 SQL 对照,非完整 App 基线;同时有其他本地验证任务运行。原相关查询在修复传参后首次实测暴露每十次 3.60~4.65 秒耗时,随后改为实体集合查询。没有新增索引或迁移,也不据此宣称整窗性能改善。
|
||||
|
||||
真实模型检查沿用 `scripts/supervision-production-live.ts` 的环境文件读取与 `ModelAgentRuntime` 无工具模式,由新增合成验证脚本执行。`node --import jiti/register scripts/supervision-audit-validation.ts live <临时父目录> .env.deepseek.local` 只发送一条 147 字符合成消息,真实生产工厂和文件 Worker 完成提取、保存及故事归属。外部请求准确为 **2 次**:提取 1 次、故事归属 1 次;无变化重跑新增请求 0 次。原文片段精确重建,外键与完整性检查通过,总耗时 3.84 秒。脚本上限为 3 次 HTTP 请求、每次模型阶段 45 秒、1,800 输出 token;凭据仅在进程内读取,终端与指标文件只输出计数和验证信息。本机继承的 TLS 环境设置未由本次修改。
|
||||
|
||||
报告位于系统临时 `opencode/supervision-audit-Ggn5uW/metrics.json`(规模检查)和 `opencode/supervision-audit-WNu08s/metrics.json`(模型检查)。只写隔离合成库,未读取或修改用户库,未运行完整 App/Electron 回归或整包构建;没有提交。修改位于桌面监督服务及其 SQLite Worker,GoodBuddy Agent 与远程 Runtime 控制路径未改变。
|
||||
|
||||
## 2026-10-04 螺旋工具栏与事件颜色修正
|
||||
|
||||
接续未提交的回顾布局调整。平铺与螺旋共用标题工具栏样式,项目层级菜单放在面包屑旁,画布内的视角入口和左下角圈数说明已移除。默认斜俯视,拖动及键盘操作保留;两种图谱均以共享 InlineHelp 的 i 图标打开图例。共用历史回顾栏、下钻返回、详情选择与中窄窗口详情面板保留。当前规则见 [UI 设计](./ui-design.md#故事线图谱)。
|
||||
@@ -138,7 +219,7 @@ FR-S13 已接通监督者“设置 → 模型”、应用设置持久化和 Main
|
||||
|
||||
- 叶子批次输出不可用(无效 JSON、结构不符、引用不存在的来源或实体)时,在同一段待处理正文上缩小到 1/2、再到 1/4(不少于 500 字符)重试,最多 2 次;缩小只是先处理前一部分,其余正文留在待处理里,不跳过、不截断。
|
||||
- 导航合并输出不可用时,用相同输入再问 1 次。
|
||||
- 提供方错误、超时、取消和响应超出容量不重试,仍按原来的方式失败并保留已保存批次。
|
||||
- 当时提供方错误、超时、取消和响应超出容量不重试,失败保留已保存批次。2026-10-05 起临时提供方错误采用上文的一次重试;超时、取消和响应超出容量仍不重试。
|
||||
|
||||
改后结果(DeepSeek `deepseek-flash`):
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
Main 的手动 IPC 和心跳完成回调均使用 `createProductionSupervisorService`,该工厂连接 `SupervisorService`、`SupervisionReviewStore`、共享模型池及无工具 Runtime。生产路径不再调用旧的 20 会话 × 20 消息 collector,也不使用旧服务分支的 100 条 / 48,000 字符裁剪。旧的有界服务注入方式仍供原有测试和实验脚本使用;心跳报告本身继续使用原报告预算。
|
||||
|
||||
2026-10-05 起,监督模型包装层对可识别的临时失败统一重试一次,包含 429、可恢复 5xx 和网络/响应流中断;取消、模型超时、配置及容量错误不重试。每次尝试独立计时,等待可取消,成功批次保留。具体错误与生命周期合同见[技术设计](./technical-design.md#模型阶段超时与调度);下文旧草案中“不增加节点网络重试”的描述已由此规则替代。
|
||||
|
||||
### 已接入的调度与存储
|
||||
|
||||
schema 47 增加运行配置、来源清单、成功批次、导航节点四张表和一个实时来源视图。迁移只创建新结构,保留此前迁移、结果及用户数据。清单只保存来源身份、版本、长度及增量起点;正文随后按主键和有界 `substr` 读取,派发顺序仍由 `(project_id, conversation_id, sequence, source)` 决定。
|
||||
@@ -50,7 +52,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
| `supervisionReview.executionSeconds` | 300 | 30..3600 | 旧设置兼容字段,执行时忽略,设置页不再展示;恢复旧 run 同样不按此值暂停 |
|
||||
| `supervisionReview.crossProject` | 关闭 | 开关 | 创建回顾时冻结;关闭时每批只以本项目已有实体为候选,也不生成跨项目故事;开启后候选来自全部项目,故事归属可建立跨项目故事 |
|
||||
| `supervisionReview.storyThreadEvents` | 20 | 4..500 | 创建回顾时冻结;功能的事件数低于此值时,模型提出的子线索并入功能 |
|
||||
| `supervisorOrganizeTimeoutSeconds` | 240 | 30..600 | 创建回顾时冻结,节点获槽并解析 Runtime 后计时 |
|
||||
| `supervisorOrganizeTimeoutSeconds` | 240 | 30..600 | 创建回顾时冻结,节点获槽并解析 Runtime 后,每次模型尝试独立计时 |
|
||||
| `heartbeatReportTimeoutSeconds` | 240 | 30..600 | 维持报告发起时冻结及既有租约规则 |
|
||||
| `supervisorModelConcurrency` | 1 | 1..4 | 回顾保存接纳并发;共享池上限仍随应用设置实时变化,实际执行取较小值 |
|
||||
|
||||
@@ -59,7 +61,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
|
||||
### 未完成与实测限制
|
||||
|
||||
- 真实验证覆盖一个 27 条消息的完整会话;较早的 129 条消息实验在第二次输出 JSON 无效后停止。两实验合计 5 次付费请求,没有重复付费或回退。范围及缓存复核说明见[进度](./progress.md#2026-09-24-完整会话生产验证与收尾)。
|
||||
- 来源被修改或删除时保留旧批次并明确失败,标记需要新建回顾,活动页停止提供无效的继续按钮;下一次自动检查可创建新 run,不会永久重复该失败。尚未实现仅使受影响叶子和父节点失效后重算。清单记录冻结版本,不保存全库正文快照。
|
||||
- 现存来源修改仍明确失败并要求新建回顾;会话物理删除时省略该会话来源,丢弃受影响的未发布叶子及依赖导航,保留其他进度。混合批次重算及覆盖规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。清单记录冻结版本,不保存全库正文快照。
|
||||
- 分块配置、超时、接纳并发及算法版本已保存。监督者模型连接在每次执行开始时解析,同次回顾复用;继续或重试读取当前选择,规则见[监督者模型选择](./logic-design.md#监督者模型选择)。候选背景仍在执行时读取;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。
|
||||
- 元数据初始化已移到 Worker,并通过 2.43 GB 隔离副本验证;[测量记录](./progress.md#2026-10-04-回顾初始化阻塞修复)包括取消与来源一致性。最终发布仍在 Main 同步提交,未完成同等规模的全局发布延迟验收。批次详情已分页,既有历史图谱查询仍返回整份结果;大图谱的响应大小尚未改造。
|
||||
- 没有 token/费用预算、提供商 RPM/TPM、Retry-After 冷却、执行硬期限、独立清理期限或逐层可配置超时。默认正文预算不保证任意模型上下文容量;超长输出、非法 JSON 和引用错误会失败,保留已成功批次。
|
||||
@@ -284,7 +286,7 @@ run(request):
|
||||
| 执行软预算到达 | 停止读页、接纳和派发,在飞节点在节点及硬期限内结束并保存成功批次 | 未完成时显示预算暂停,可继续;若已满足全部完成条件则可正常发布 |
|
||||
| 批次或发布事务失败 | 该事务回滚;此前成功批次不回滚 | 从最后持久化位置恢复;发布失败不重算已有合法叶子 |
|
||||
| 达到采集预算,已接纳任务全部成功 | 保存有限覆盖批次及位置,仍有未读内容 | 显示“未完成,可继续”,不以 `completed` 加提示代替真实状态 |
|
||||
| 有来源读取失败或运行中被删除 | 初版按本轮失败处理,不将读取失败当作没有新证据 | 显示来源不可用的数量和范围,后续重新查询 |
|
||||
| 有来源读取失败或运行中被删除 | 会话物理删除按[省略规则](./logic-design.md#未发布回顾遇到会话删除)继续;其他读取失败仍报错 | 省略数独立于保留来源的覆盖统计 |
|
||||
| 自动无新来源 | 保存 `no_change`,无新结果或图谱事件 | 保留旧结果,模型调用数为零 |
|
||||
|
||||
成功批次持久化是用户已授权的续跑要求。最小记录包含 run 冻结范围、目录/页位置、来源 revision 与连续 offset、已校验叶子输出及引用、必要的汇总依赖和成功输出。输出与对应位置同事务提交,以 run 和来源区间等稳定身份防止重复保存;不持久化每次进度动画或完整提示词。崩溃前未提交的在飞批次允许重算,已提交成功批次必须复用。表结构及保留/清理实现仍待设计落地,不能声称现有 schema 46 已支持此能力。
|
||||
|
||||
@@ -55,6 +55,8 @@ FR-S8:本地知识库提供双向定位:资料详情可进入相关故事线
|
||||
|
||||
FR-S9:来源事实、自动归纳、待核对关联和用户确认内容分别标注。移除关联不删除原文,纠正操作可撤销。来源失效时显示不可用状态;回放历史时使用当时已有的归纳与依据,无法恢复旧版原文时明确提示,不能以当前版本冒充历史内容。
|
||||
|
||||
未发布回顾遇到用户删除会话时,跳过该会话来源并继续其他会话,明确显示省略数;无剩余来源时结束且不生成空报告。已发布历史的保留规则不变。具体失效与覆盖语义见[会话删除规则](./logic-design.md#未发布回顾遇到会话删除),验收见 US-S35。
|
||||
|
||||
## 落地范围
|
||||
|
||||
FR-S11(D1 已实现,D2/D3 待实现):监督者以时态 Story Graph 为主要产出,统一用户侧工作记忆概念并保留已确认偏好与已有数据。Agent 在继续工作、查询此前决定及其修订时,按需使用只读的 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,读取范围、时间、演变、覆盖及可引用来源;工具不调用模型或触发回顾,监督者关闭时不暴露且拒绝调用。本地和远程 Runtime 共用读取语义。后续目标为同一来源版本提取一次并跨 scope 复用叶子事实;当前精确 scope 独立处理及 memory 读取继续保留。`as_of` 仅在历史重建能力具备后另行交付。合同与过渡阶段由[专项设计](./story-graph-mcp-design.md)定义,验收见 US-S28 至 US-S31;本轮没有数据迁移。
|
||||
|
||||
@@ -9,15 +9,17 @@
|
||||
| 产品设计 | [监督者应用产品设计](./supervisor-prd.md) |
|
||||
| 行为规则 | [监督者逻辑设计](./logic-design.md) |
|
||||
| 界面设计 | [监督者 UI 设计](./ui-design.md) |
|
||||
| 实施进度 | SQLite schema 50(心跳介入与监督建议见[心跳触发与建议](#心跳触发与建议2026-09-30));当前算法、设置和验收边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界) |
|
||||
| 实施进度 | SQLite schema 62;页面读取与索引见[页面读取与发布](#页面读取与发布2026-10-05),当前算法、设置和验收边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界) |
|
||||
|
||||
本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。
|
||||
|
||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。
|
||||
FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。当前监督阶段使用共享时间线 checkpoint;候选按下文的批次项目与跨项目设置筛选。memory 继续作为背景读取,结果快照不支持 `as_of` 历史重建。
|
||||
|
||||
生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和无工具 Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。
|
||||
|
||||
模型实体 `id` 只在单次输出内有效。Main 从同一 scope 的故事线提供最多 100 个未撤销实体候选,生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验故事线归属及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。
|
||||
模型实体 `id` 只在单次输出内有效。生产服务将批次的 `projectId` 和 `crossProject` 经 `supervisionCandidates` 传到只读 Worker,再由 `listSupervisionCandidates` 查询共享时间线。跨项目关闭时,只提供该项目当前事件关联的未撤销实体;开启时允许其他项目的实体。候选仍最多 100 个,按更新时间和 ID 排序。查询先取得项目事件关联的实体集合,再筛选候选,避免对每个实体重复扫描事件。省略批次参数的旧读取入口保留精确 scope 查询。
|
||||
|
||||
生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验存在性及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。
|
||||
|
||||
新实体省略 `candidateRef`,不要求模型生成数据库 ID。未知非空 `candidateRef`、冲突身份和候选集之外的 UUID 均拒绝。旧输出中的 `persistedId` 仅在 UUID 合法且属于候选集时复用;空值、占位符和格式错误值不能证明已有身份,保留为独立新实体。局部 ID 或同名不会触发合并。
|
||||
|
||||
@@ -31,7 +33,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
升级从现有事件实体关联、实体变化和来源引用恢复结果成员,不猜测名称匹配;旧数据缺失的归属或已经覆盖的内容无法还原,原对象仍保留。新结果即使实体没有事件或来源,也会保存完整成员。结果内容与运行、事件、来源在同一事务提交。
|
||||
|
||||
`overview({ target? })` 返回稳定的 `id`、`storyLineId` 及来源 ID。指定 Conversation/Task 时,Main 读取目标实际项目,查询同时要求目标来源匹配、结果 scope 为 global 或包含该项目;LIMIT 在过滤之后。卡片展示结果真实范围,global 结果不会伪装成单会话摘要。`graph({ resultId, storyLineId? })` 校验二者归属,只返回该结果的事件、对象和来源;显式 ID 无效时不回退到最新结果。继续讨论按结果主键读取,并验证来源属于该结果。
|
||||
`overview({ target? })` 返回稳定的 `id`、`storyLineId` 及来源 ID。指定 Conversation/Task 时,只读 Worker 读取目标实际项目,查询同时要求目标来源匹配、结果 scope 为 global 或包含该项目;LIMIT 在过滤之后。卡片展示结果真实范围,global 结果不会伪装成单会话摘要。`graph({ resultId, storyLineId? })` 校验二者归属,只返回该结果的事件、对象和来源标题;显式 ID 无效时不回退到最新结果。继续讨论按结果主键读取,并验证来源属于该结果。
|
||||
|
||||
工作回顾保存所选结果,切换或刷新使用请求序号忽略迟到图谱。侧栏按目标重建卡片,清空来源和预览,并忽略旧请求;工作栏 tasks 实例的 `targetRef` 可保存 conversation/task 固定目标,取消固定后恢复跟随当前选择。该绑定只控制监督卡片,不改变任务列表的项目范围。继续讨论预览显示实际目标名称和 ID,发送回调显式携带该会话 ID,由 Main 使用该会话的 Runtime 与项目。
|
||||
|
||||
@@ -43,6 +45,20 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
已确认记忆作为当前背景,不参与新增正文覆盖。最多四条、每条 500 字符,时间来自真实更新时间;模型不能把背景引用为本批新增证据。旧结果继续保留原来源类型和 locator。监督正文上限现用于分批,余段继续处理;心跳报告的有界输入语义没有改变。
|
||||
|
||||
## 页面读取与发布(2026-10-05)
|
||||
|
||||
对应 K01 的来源投影及 K02、K05、K06。`supervision:overview`、`supervision:graph`、`supervision:stories` 的生产 IPC 校验后分别调用 `listSupervisionResultsAsync`、`getSupervisionGraphAsync`、`getSupervisionStoriesAsync`。它们复用 `readonly-query-worker` 的 AssistantDatabase 独立只读连接,每次领域读取包在一个 `readSnapshot` 内;故事响应中的故事、经验、未归属计数及撤销状态来自同一 SQLite 快照。调用前已提交的写入对下次读取可见。
|
||||
|
||||
文件数据库缺少 Worker 配置、启动失败、崩溃或处于原有退避期时,请求直接失败,页面沿用既有错误与重试显示;这三个入口不使用 `readWithFallback`,不在 Main 重跑重查询。内存 SQLite 测试保留同步分支。原工具 `readStoryGraphAsync`、来源详情及其他存储入口保持各自原有实现,没有增加存储进程或通用协议。
|
||||
|
||||
结果图谱和 scope 图谱的 `sources` 都只查询共享合同已有的 `id`、`title`、`occurred_at`,并按原表 rowid 顺序返回。正文、来源类型、locator 等仍完整保存在 `supervision_sources`,通过 `supervision:source`/`source-context` 按 ID 读取;历史结果、所有事件、对象和关联均保留。概览是否请求图谱由 Renderer 的页面加载规则决定,后端不截断图谱内容。
|
||||
|
||||
schema 62 在既有迁移事务中新增 `supervision_sources(result_id, occurred_at, source_type, id)` 和 `supervision_results(created_at)` 索引。前者供每份概览的首来源定位,后者支持结果时间及 rowid 倒序读取。升级沿用启动存储 Worker,保留表内容和 rowid,不重写正文,不触发历史转换或 `VACUUM`。新版本仍遵守既有的禁止旧客户端打开更高 schema 规则。
|
||||
|
||||
故事查询先按 scope 选择故事,再一次读取这些故事的事件成员;经验查询在 SQL 中筛选仍有形成事件、且与所选项目有关的经验,再一次读取其全部有效事件关联。经验的跨项目关联不会随项目筛选被裁掉。两类列表各至多两条 SQL,原列表和成员排序、人工调整状态及完整内容访问保留。
|
||||
|
||||
发布继续使用 Main 原有 `BEGIN IMMEDIATE` 事务。每个叶子写入后收集当时的实体、关系及来源引用,末尾只写一次 `graph_snapshot_json`;重复持久身份仍采用原 SQL 分组规则,保留首叶子快照值和全部叶子详情。结果、运行状态、来源、时间线与 checkpoint 仍一起提交或回滚。没有将写入迁到 Worker,也没有把一个完整结果分多次提交。大批量发布仍超过 Main 16 ms 预算,实测与剩余限制见[实施进度](./progress.md#2026-10-05-监督页面读取与发布局部优化)。独立存储服务的职责迁移继续留在[目标方向](../../architecture/desktop-storage-direction.md)。
|
||||
|
||||
## 监督者模型连接
|
||||
|
||||
`ApplicationSettings.supervisorModelProfileId` 只保存已有连接 UUID,`null` 或旧设置缺少字段时跟随 `RuntimeSettings.defaultModelProfileId`。Main 的 `resolveSupervisorRuntime` 从 `getResolvedSettings()` 读取连接和已有凭据,校验文本协议及认证后,用 `createModelProfileRuntime` 创建无工具直连 Runtime。Renderer 仅接收公开连接信息,不新增凭据存储或 IPC 方法。
|
||||
@@ -53,9 +69,13 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
监督整理超时由 run 创建时冻结;共享模型池仍采用实时设置上限。`supervision:pause` 取消该 run 的排队和在途工作,`supervision:resume` 继续已保存批次;监督整理输出在流中按 `supervisionReview.responseKiB` 检查响应容量,默认 1024 KiB,可在设置页调整为 100 至 16384 KiB。该容量每次请求读取当前设置,继续旧运行也采用新值;超限保留成功批次并提示调高后继续。生成摘要及事实内容不设独立短字符限制,监督旧摘要读取保留全文,具体边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。以下报告领取和租约规则继续适用。
|
||||
|
||||
手动和自动监督均在桌面 Main 调用生产工厂,Runtime 解析请求不携带项目、Runtime 选择或执行空间,也不再注入 `workMode`。`resolveRequestRuntime` 返回桌面默认 Runtime,不进入 SSH 项目或 `selectedRuntimes.getRuntime` 分支;内部分析使用无工具构造,回顾 scope 只筛选证据。摘要校验、合并和持久化在 Main 完成,single-flight、取消及 UI 控制不改变 `gbagent`、远端 Runtime 启动或桌面到 Agent 协议。
|
||||
手动和自动监督均在桌面 Main 调用生产工厂,由 `resolveSupervisorRuntime` 解析上文的直连模型,不进入 SSH 项目或 `selectedRuntimes.getRuntime` 分支;内部分析使用无工具构造,回顾 scope 只筛选证据。摘要校验、合并和持久化在 Main 完成,single-flight、取消及 UI 控制不改变 `gbagent`、远端 Runtime 启动或桌面到 Agent 协议。
|
||||
|
||||
`ApplicationSettingsStore` 持久化报告和整理模型超时,均为 30..600 整数秒,默认 240。报告排队前冻结,监督创建 run 时冻结;计时从获槽及 Runtime 解析后开始,覆盖模型执行及其内部重试。流结束校验 signal 和完成事件,finally 释放临时会话,心跳报告保持原有 100KB 上限,监督整理使用上文的可调响应容量。采集、排队、保存和清理不计入模型超时。
|
||||
`ApplicationSettingsStore` 持久化报告和整理模型超时,均为 30..600 整数秒,默认 240。报告排队前冻结,监督创建 run 时冻结;计时从获槽及 Runtime 解析后开始,覆盖单次 Runtime 执行及其内部重试。监督包装层的第二次尝试重新按同一秒数计时,500ms 等待和临时会话清理不计入该时限。流结束校验 signal 和完成事件,finally 释放临时会话,心跳报告保持原有 100KB 上限,监督整理使用上文的可调响应容量。采集、排队、保存和清理不计入模型超时。
|
||||
|
||||
`runSupervisionModel` 为提取、导航、故事、经验和建议共用一次临时错误重试,错误范围见[逻辑规则](./logic-design.md#监督者模型选择)。每次尝试使用新的 request/conversation ID 和隐藏任务,正文缓冲单独创建;用量按各次任务保存。失败后先清理计时器并释放临时会话,再等待;等待继续持有监督池槽位,受父 signal 和池取消控制,结束或失败后释放槽位。`error` 事件的 `status: cancelled` 优先于错误文字;抛出的结构化错误检查 status、statusCode 和 cause,字符串只识别明确的 HTTP 状态或已知网络/流中断格式。
|
||||
|
||||
直连文本请求保留 HTTP 错误的 status,并在响应流失败前交出已收到的用量;没有提供方用量时不估算补记。监督响应超容量后立即 abort 并清空正文,最多继续消费 1 秒,仅保存用量事件,不累积后续文本;随后请求关闭迭代器、释放临时会话和池槽位,迟到事件不再写入。容量错误保持原文,不被随后到达的超时或流错误替换,也不重试;已缓冲完成响应和中止响应均沿用原 call ID 记账。重试决策只在监督包装层,直连传输原有重试、OpenCode 和 Continue 策略不变。429 使用同一 500ms 等待,不新增 Retry-After、UI 或配置项。
|
||||
|
||||
`supervisorModelConcurrency` 为 1 至 4 的整数,默认 1。报告和监督整理共用 Main 内 `SupervisionModelPool` FIFO 池,普通聊天不入池;降低上限不终止在途请求。报告槽位从领取前持有至报告完成或无变化提交后,在下游监督开始前释放,避免并发 1 时相互等待。监督服务外层运行仍串行;该池不是完整分块调度器,也不提供跨聊天与监督的提供商全局并发、RPM、TPM 或 Retry-After 控制。
|
||||
|
||||
@@ -63,6 +83,10 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
schema 47 的四张批次相关表使用外键随监督运行清理,未完成运行不新增自动过期删除规则。启动时原有 running 记录会标 failed,但已保存批次和配置保留,活动页仍可继续。发布逐页读取叶子并复用现有结果/来源/图谱写入,在同一事务更新完成状态和自动 checkpoint。
|
||||
|
||||
会话删除复用 `SupervisionReviewStore.omitDeletedConversations`,只处理 `running/paused/failed` 的暂存记录。删除入口在事务内补齐旧任务清单的会话归属;新清单由 Worker 在初始化时记录任务所属的现存会话。初始化完成、恢复、分块读取、模型调用前及返回落库、导航合并和发布前均检查删除。初始化中的清单仍可丢弃重建,省略计数在完整清单提交后计算。
|
||||
|
||||
失效计算只取批次的来源键,不把保存正文作为删除会话的替代输入;依赖导航沿 `children_json` 递归删除。省略数写入既有 `state_json.omittedSources`,透出到活动进度及结果 `coverage_json`,不增加 schema。发布事务先核对省略数与待发布结果一致,再保存事实和 checkpoint;若删除使摘要过时,服务返回现有提取/合并循环。已发布历史不参与失效,行为定义见[逻辑规则](./logic-design.md#未发布回顾遇到会话删除)。
|
||||
|
||||
当前设置及冻结范围见[当前设置合同](./review-scheduling-design.md#当前设置合同)。未实现的高级参数继续保留在目标清单,不显示为可保存控件;没有兼容原型数据库的读取器。
|
||||
|
||||
### 单次执行与取消
|
||||
@@ -71,6 +95,10 @@ schema 47 的四张批次相关表使用外键随监督运行清理,未完成
|
||||
|
||||
`supervision:execution` 返回进程内 `{ active, runId?, stopping? }`,配置读取期间可有 `active: true` 而尚无 runId。`supervision:cancel({ runId })` 先在既有 `supervision_runs` 写入 `cancelled`、清除错误并保存取消时间,再向父 controller 传播 abort;重复取消保留原时间。该接口等待执行 Promise 结束,包含并行批次的 `allSettled` 和 Runtime `releaseConversation`,之后才释放准入。暂停只请求 abort,执行停止后保存 `paused`;取消优先于暂停。迟到模型输出不得保存叶子、导航或完整结果,成功批次保留,自动 checkpoint 不推进。发布是同步 SQLite 事务,已完成或无变化的运行拒绝取消,不撤销已发布结果;本次无 schema 迁移。
|
||||
|
||||
发布前重读已保存批次所需候选后,再检查父 signal。此时收到暂停或取消,走原有停止分支;完整叶子和导航保留,结果及 checkpoint 尚未提交。该检查与同步保存之间没有新的异步等待。
|
||||
|
||||
故事归属和子线索拆分在既有写事务内,按主键检查模型选中的目标、父故事和跨项目功能仍为 `current`。模型等待期间若用户移除或合并目标,整批写入回滚,包含新建故事和已处理标记;重新整理使用当前候选。拆分同样检查原功能及目标子线索。已发布回顾、用户调整和此前成功批次保留,不增加版本表或恢复日志。
|
||||
|
||||
Preload 暴露类型化 `execution`、`cancel`,取消输入使用 UUID schema 并校验可信 sender。活动 UI 用实时执行状态覆盖持久记录的停止显示;工作回顾只在发起前查询执行状态,Main 仍作最终准入判断。`resume` IPC 直到运行结束才返回,Renderer 发出请求后释放提交锁并独立处理结果,确保继续中的回顾仍可暂停或取消。
|
||||
|
||||
`App` 将既有通知回调经 `HeartbeatCenter` 传给 `SupervisorWorkspace`。回顾操作用 `supervisor-review` 去重键发送本地化 Toast,结果读取失败仍写入 `loadError` 并就地重试;实体、关系操作和来源读取失败也使用通知。工作回顾不再为状态条轮询执行状态,历史读取不发送操作通知。请求锁覆盖发起前检查和整次运行;页面刷新只使旧结果选择失效,不丢弃运行结束反馈。卸载后忽略迟到响应,检查阶段尚未提交的回顾不再发起,已提交的后台运行不取消。活动查询、原始错误持久化和 Main 生命周期保持原路径。
|
||||
@@ -98,7 +126,7 @@ IPC `supervision:suggestions`、`supervision:suggestion-action`、`supervision:r
|
||||
|
||||
只有实际送入模型、通过结果校验且成功落库的完整片段推进位置。心跳报告及其进度、监督结果及其进度分别在各自 SQLite 事务提交。监督服务串行执行,后续自动请求在前次保存后重新收集。报告成功而监督失败时,下一次自动检查跳过已成功的报告输入,但重试监督输入;无变化不创建报告、来源或图谱事件。
|
||||
|
||||
已有同 scope 故事线的 scope 表示和实体 ID 保留;比较项目集合时忽略排序。模型接收最近摘要和同 scope 最多 100 个未撤销实体的候选引用、名称、说明,显式选择候选才复用,身份转换按上文执行。不同范围不合并,已有重复实体不自动修复。知识引用继续以独立来源保存 locator;当前记忆和旧摘要不作为新事件。
|
||||
已有故事线的 scope 表示和实体 ID 保留;比较项目集合时忽略排序。生产候选按上文的项目归属与跨项目设置读取,显式选择候选才复用;已有重复实体不自动修复。监督 checkpoint 的共享时间线规则见[故事线模型](./storyline-model-design.md)。知识引用继续以独立来源保存 locator;当前记忆和旧摘要不作为新事件。
|
||||
|
||||
迁移在事务中扩展心跳计划及运行表的状态 CHECK,保留列、索引、ID、计划、报告与引用;消息版本列使用默认值,无需遍历或重写旧正文。进度初始为空,首次自动运行从配置窗口开始。报告保留期限覆盖 `no_change` 运行,但不删除来源进度;删除报告不触发重新消费。
|
||||
|
||||
@@ -326,7 +354,7 @@ IPC 返回图谱查询结果时使用分页和有界字段;详情中的来源
|
||||
## 当前剩余差距
|
||||
|
||||
- 实体与关系 revoke 保存状态;界面没有 undo 或恢复入口。
|
||||
- 跨 run identity 依赖同 scope 候选 UUID 的显式引用,不自动合并历史重复实体;模型漏选候选时会创建新身份。
|
||||
- 跨 run identity 依赖候选 UUID 的显式引用,候选范围受批次项目及跨项目设置控制;不自动合并历史重复实体,模型漏选候选时会创建新身份。
|
||||
- 结果图谱使用真实事件实体连线,侧栏已按固定目标过滤并支持结果直达图谱;逐事件状态回放和 Experiment 尚未实现。
|
||||
- 用户暂停已接到排队/在途 signal,并保留已保存批次;源版本变更后的局部重算、完整模型配置冻结及其他限制见分块调度第 0 节。
|
||||
- 监督知识实体与知识库正文条目仍是不同对象,知识正文只能通过现有条目定位和预览接口处理。
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
工作回顾与故事线图谱在主页签下方共用回顾上下文栏。历史选择器最多占 280px,收起时只显示生成日期时间,随后是“刷新”和“新回顾”,不在右侧重复范围和覆盖区间。展开复用共享锚定单选菜单,每项显示生成时间及该结果自身的真实范围、覆盖起止时间,长说明换行;当前项带选中标记,触发按钮的无障碍描述保留所选结果的完整上下文。即使只有一条结果也显示选择器。切换页签和图谱模式时,这一栏的位置、内容和选择保持一致,空态及读取失败时仍可访问。
|
||||
|
||||
首次打开工作回顾只读取回顾及其范围的故事摘要,不等待图谱。进入故事线图谱时才读取所选结果的图谱;读取期间在图谱页显示加载状态,返回工作回顾仍可阅读及选择历史。工作回顾与图谱共用已读的故事数据,普通页签切换不重复读取。当前结果的图谱在切换页签时保留;切换历史结果、显式刷新、实体或关系修改后的刷新会使旧图谱失效。来源列表只需标题和时间,点击来源才读取完整正文。
|
||||
|
||||
点击“新回顾”才展开范围、时间、发起按钮和重新整理菜单。表单位于上下文栏下方,以次级表面区分新请求与已选结果;再次点击收起并保留输入。工作回顾正文铺满页面壳层,不另设阅读宽度上限。容器宽于 860px 且有辅助内容时,左侧约占三分之二,显示工作总结和本次变化;右侧显示未解决事项、故事进展和经验,以弱分隔线分区。没有辅助内容时只保留全宽正文。窄屏按正文、辅助内容顺序排列为单列。摘要保留原有段落和换行,故事摘要保留“显示更多”及进入图谱的选择。
|
||||
|
||||
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用。手动发起、完成、暂停、取消、无变化和失败统一使用应用 Toast;重复点击发起提示已有运行,不新建或排队。同一回顾操作的通知互相替换,关闭通知不暂停或取消执行。成功和信息沿用共享自动消失时间,错误由用户关闭。页面不保留操作状态条或错误横幅,加载历史结果、切换页签和重新进入页面不触发回顾通知。
|
||||
@@ -44,6 +46,8 @@
|
||||
|
||||
“已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾主动暂停后在工作回顾页提示去活动继续,不显示完成通知。
|
||||
|
||||
删除会话导致省略来源时,阶段进度下方和运行详情均显示省略数量,并说明覆盖统计只含保留来源。中文为“会话已删除,已跳过 N 个来源”;英文为“N sources skipped because their conversations were deleted”。全部省略时活动显示无新变化,不提供空结果跳转;已发布历史仍可查看。规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。
|
||||
|
||||
活动页运行中同时提供“暂停回顾”和“取消回顾”,继续后的运行也可操作。停止期间禁用两项操作,并在运行标题和当前阶段显示“正在暂停”或“正在取消”;即使持久记录已经为已取消,只要执行尚未停止就继续显示“正在取消”。重新进入活动页通过实时执行状态恢复该提示。停止后显示“已取消”,保留事实详情,不显示继续按钮或失败恢复提示;任一回顾执行或停止清理期间,其他记录的继续按钮禁用。
|
||||
|
||||
## 智能心跳设置
|
||||
@@ -86,9 +90,17 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
2026-09-30 已实现(规则见[心跳职责与介入](./logic-design.md#心跳职责与介入目标设计)):主按钮“回顾”执行增量整理,文案不再称“重新分析”;旁边“更多”菜单提供“重新整理…”,确认对话说明将重新分析所选区间并可能产生模型费用。计划 Modal 增加“介入方式”单选:“生成建议”“仅更新记忆”;主动介入上线前不显示。最近建议替代,每条建议显示依据、打开来源及转任务/确认/忽略。“最近建议”替代,每条建议显示依据、打开来源及转任务/确认/忽略。
|
||||
|
||||
当前页面提供全局或单项目范围,以及 1、7、30 天时间区间选择。没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。
|
||||
当前页面提供全局或单项目范围。时间选择保留 1、7、30 天快捷项,默认 7 天,并提供“自定义天数”和“指定日期”。快捷项与自定义天数均从提交时刻向前计算,每天按 24 小时计;自定义值必须为正整数,且起始时间不得早于公元 1 年。
|
||||
|
||||
选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
|
||||
指定日期使用带可见标签的原生日期输入,按本地时区解释。起始时间为开始日期的零点,结束时间包含结束日期全天:取下一个本地日期的零点减 1 毫秒,跨夏令时不按固定 24 小时推算;结束日期为今天时截到提交时刻。允许同一天,拒绝缺失、无效、未来日期及开始晚于结束。天数和日期输入复用共享字段样式,窄窗口换行;错误就地显示,通过 `aria-invalid` 和 `aria-describedby` 关联输入,无效时禁用回顾和重新整理,提交入口再次校验。
|
||||
|
||||
重新整理确认显示当前范围及最近天数或明确起止日期,日期文案说明本地日期和今天截至当前的规则。修改范围或时间输入后关闭旧确认,重新整理仍需再次确认。新选择只改变请求时间范围,普通回顾继续跳过已处理且未变化的来源,重新整理仍使用既有显式标记,不改变增量处理规则。
|
||||
|
||||
没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。
|
||||
|
||||
选择历史结果后,历史菜单显示该结果的回顾区间和生成时间,旧图谱和来源详情清空。在图谱页选择历史时立即读取对应成员和内容;在工作回顾页选择时,图谱读取留到下次进入图谱页。迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
|
||||
|
||||
切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。工作回顾在回顾读取完成后恢复历史选择和刷新;图谱页等待对应图谱读取完成后恢复操作。图谱失败只在图谱页提示并提供重试,不阻止阅读已读回顾;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。
|
||||
|
||||
手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。
|
||||
|
||||
@@ -123,6 +135,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
左侧使用共享 PageTabs 切换“事件 / 实体 / 关系”,显示各类数量,一次只展示一类列表,无记录时显示本次回顾的分类空态。页签固定在顶部,列表占满剩余高度并独立滚动。切换页签不清除当前图谱选择;从画布、事件浏览或详情选择对象时同步切到对应页签。支持方向键、Home/End 和面板键盘聚焦。
|
||||
|
||||
事件、实体和关系长列表复用共享列表窗口,只挂载视口附近的行,并保留选中行和焦点行。记录总数、完整名称及末条记录均可访问,不以截断记录提速。列表内上、下方向键移动焦点,Home/End 到首、末条,Enter 或 Space 选择;Tab/Shift+Tab 按完整记录顺序移动,在首尾离开列表。键盘移动跨过未挂载的行时,先挂载目标行再聚焦。行高测量或窗口变化不得把刚聚焦的可见行移出视口;用户主动滚走时保留焦点行,但不把滚动位置拉回。
|
||||
|
||||
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
|
||||
|
||||
高度按窗口剩余可见空间计算,底部保留约 24px 边距(不小于页面底部内边距)。平铺图最低 660px,时间螺旋最低 320px;低于最小高度时页面允许纵向滚动。中间画布不内部滚动,工具栏与事件浏览栏之外的空间给图;左侧列表和右侧详情各自滚动。两种视图的图例均使用共享 InlineHelp 的纯 i 图标,保留可访问名称和 title;点击或 Enter/Space 固定展开,再次点击、Escape、点击或聚焦外部关闭。平铺可见数量仍在画布角落;螺旋左下角不显示圈长及圈数,这些信息收进图例。窗口尺寸及上方表单展开、收起后重新计算可用高度。
|
||||
|
||||
@@ -650,6 +650,19 @@
|
||||
| 已选连接被删除或变成图片模型 | 打开设置并尝试执行 | 显示连接不可用,执行报错,用户可改选;不自动回退 |
|
||||
| 模型选择保存失败 | 留在设置页 | 保留选择草稿及重试入口 |
|
||||
|
||||
### US-S35 删除会话后继续未发布回顾
|
||||
|
||||
对应 FR-S9,规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。
|
||||
|
||||
| Given | When | Then |
|
||||
| --- | --- | --- |
|
||||
| 回顾已保存批次但尚未发布 | 删除其中一个会话后继续 | 跳过该会话的消息、知识引用和已记录归属的任务,保留无关批次 |
|
||||
| 来源正在初始化、提取、合并或等待发布 | 用户删除会话 | 丢弃受影响输出及依赖摘要,继续其余来源,不从片段恢复会话 |
|
||||
| 一个旧批次包含已删除及仍存在的会话来源 | 继续回顾 | 重做受影响的存活来源,无关来源不重做 |
|
||||
| 所有待回顾来源所属会话均已删除 | 继续或结束当前请求 | 显示省略数及无新变化,不生成空报告 |
|
||||
| 删除前已有发布结果 | 删除会话 | 既有历史结果和历史片段不变;未发布结果按省略后的覆盖发布 |
|
||||
| 会话仍存在,来源正文被修改或会话被归档 | 校验来源 | 明确失败,不把修改或归档记为删除省略 |
|
||||
|
||||
## 验收优先级与依赖
|
||||
|
||||
首个可用版本应先打通以下路径:
|
||||
|
||||
@@ -365,6 +365,18 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创
|
||||
同次验证复现原生 ACP 冷启动超过 15 秒控制超时。`runtime/startPrompt` 的控制等待
|
||||
现至少为 30 秒,其余控制请求沿用原时限;Runtime 外层仍保留启动超时和取消处理。
|
||||
|
||||
2026-10-05 修复启动等待阻塞控制队列的问题:原生启动等待同时释放 backend 全局控制
|
||||
队列和协议接收分派,取消、watchdog 及其他 binding 的控制请求可继续处理。相同启动
|
||||
请求只执行一次,迟到返回的已取消 Session 单独关闭;不增加 Prompt 总时限或新协议
|
||||
阶段。实现规则见[共享进程与 Session 所有权](../assistant-workbar/runtime-process-reuse-technical-design.md#61-连接与所有权),
|
||||
本次源码 Host 验证及调用计数见[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。
|
||||
|
||||
同日 K07 补齐模型路由准备后的取消:broker 关闭及终态提交保持原顺序,原生路由
|
||||
撤销的 HTTP 等待移出全局控制队列。安装晚于取消完成时,以捕获的 transport、Session
|
||||
和 operation 再次撤销该路由;原生 helper 只删除 operation 匹配的项。路由 HTTP 请求
|
||||
仍有 10 秒上限,transport 退出或释放时中止未完成请求。当前源码的 OpenCode/Continue
|
||||
实机证据见[局部修复验证](../unified-execution/progress.md#2026-10-05-k07k08-局部修复)。
|
||||
|
||||
## ACP 与断线
|
||||
|
||||
- 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
# Telegram 消息通道
|
||||
|
||||
Telegram 消息通道计划通过用户自行创建的 Bot,将 Telegram 私聊接入 GoodBuddy 通道项目,支持文字问答和执行任务。首版采用长轮询,运行 GoodBuddy 的电脑需要在线并能访问 Telegram。
|
||||
Telegram 消息通道通过用户自行创建的 Bot,将授权用户的普通私聊文字接入 GoodBuddy 通道项目,复用项目的模型、Runtime 和工作目录。首版采用长轮询,运行 GoodBuddy 的电脑需要在线;生产驱动通过 Electron `net.fetch` 使用系统代理访问 Telegram。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [Telegram 通道 PRD](./prd.md) | 产品目标、首版范围、配置流程、功能要求、限制与验收标准 |
|
||||
| [技术设计](./technical-design.md) | 生产模块、身份与持久化、IPC 状态、生命周期及实现边界 |
|
||||
| [实施与验证进度](./progress.md) | 日期化验证证据、当前阻塞与待验收项目 |
|
||||
| [API 联调操作说明](./api-testing.md) | 两类探针的命令、网络路径、输出含义及副作用 |
|
||||
| [独立 Node 探针](./telegram-probe.mjs) | 独立调用 Bot API,不经过生产驱动或模型 |
|
||||
| [生产探针启动器](./run-live-probe.mjs) | 构建探针、启动隔离 Electron 进程并清理临时目录 |
|
||||
| [生产探针入口](./live-probe-main.ts) | 生产驱动连接检查及可选 ChannelService 固定回复,不调用模型 |
|
||||
|
||||
当前处于 PRD 草案阶段,尚未实现 Telegram 通道。详细逻辑、UI 和技术设计在实现前按需补充,不以本目录的建立作为功能完成证据。
|
||||
生产接入代码与本地自动化验证已落地,真实 Bot 与模型的完整链路尚未证明,不能记为全面验收通过。当前结果和待办以[进度记录](./progress.md)为准;2026-10-04 独立 Node 探针成功仅是历史 API 证据。
|
||||
|
||||
## 术语与依赖
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Telegram API 联调
|
||||
|
||||
## 用途与边界
|
||||
|
||||
[run-live-probe.mjs](./run-live-probe.mjs) 启动 Electron,运行 [live-probe-main.ts](./live-probe-main.ts),检查生产驱动及可选的 ChannelService 固定回复。[telegram-probe.mjs](./telegram-probe.mjs) 是独立 Node API 诊断脚本,没有接入 GoodBuddy 项目、会话、白名单、Runtime 或发件箱。
|
||||
|
||||
两类探针都不调用模型,成功不能代替真实 Bot 与模型完整链路验收。生产驱动使用 Electron `net.fetch` 和系统代理,没有引入 grammY 或 Telegraf;独立脚本使用 Node `fetch`。当前结果和 2026-10-04 历史记录统一见[进度记录](./progress.md)。本次生产实时探针未运行,原因是 `TELEGRAM_BOT_TOKEN` 不存在。
|
||||
|
||||
## 生产驱动探针
|
||||
|
||||
从仓库根目录运行,需要已安装项目依赖(Electron、esbuild)。启动器在临时目录构建生产源码,使用隔离的 Electron 用户目录,退出后清理。可用 `--temp-parent PATH` 指定已存在的临时父目录。
|
||||
|
||||
```powershell
|
||||
node "docs/features/telegram-channel/run-live-probe.mjs" --dry-run
|
||||
```
|
||||
|
||||
`--dry-run` 只构建并检查语法,不启动 Electron,不发网络请求,不需要 Token。它不能证明代理路径可用。实时连接检查使用下面命令;隐藏输入仅放入进程环境,不写文件。
|
||||
|
||||
```powershell
|
||||
$telegramProbeSecret = Read-Host 'Telegram Bot Token' -AsSecureString
|
||||
$env:TELEGRAM_BOT_TOKEN = [System.Net.NetworkCredential]::new('', $telegramProbeSecret).Password
|
||||
try {
|
||||
node "docs/features/telegram-channel/run-live-probe.mjs"
|
||||
} finally {
|
||||
Remove-Item Env:TELEGRAM_BOT_TOKEN -ErrorAction SilentlyContinue
|
||||
$telegramProbeSecret = $null
|
||||
}
|
||||
```
|
||||
|
||||
默认仅通过生产驱动执行 `getMe` / `getWebhookInfo`,不轮询、不发送。`TELEGRAM_BOT_TOKEN` 是探针输入,产品只读环境配置使用 `GOODBUDDY_TELEGRAM_BOT_TOKEN`,两者不能混用。
|
||||
|
||||
要验证固定回复,先停用该专用测试 Bot 的其他消费者,在普通私聊发送精确文本 `/goodbuddy_test`,再将上述运行命令替换为:
|
||||
|
||||
```powershell
|
||||
node "docs/features/telegram-channel/run-live-probe.mjs" --send-test
|
||||
```
|
||||
|
||||
发送模式从首批最多 100 条积压中选择最近 24 小时内最新的合格私聊标记,临时授权该发送者,经生产驱动和 ChannelService 返回固定文字。网络适配层只把选中的事件交给驱动,其他事件不会触发帮助或任务;使用临时内存状态和固定 executor,不经过产品设置页、生产模型或持久化发件箱。
|
||||
|
||||
发送模式会启动生产轮询、设置 `allowed_updates=['message']` 并可能推进 offset,因而可能确认或消费更早的积压。仅对专用空闲 Bot 使用;这与独立 Node 探针不推进 offset 的行为不同。无匹配标记时失败退出,不等待用户补发。
|
||||
|
||||
| 输出 | 含义 |
|
||||
| --- | --- |
|
||||
| `PASS: production driver getMe/getWebhookInfo.` | 生产驱动身份及 webhook 检查成功 |
|
||||
| `PASS: production driver and ChannelService delivered the test reply.` | 固定回复 API 成功,仍需手机核对展示;未调用模型 |
|
||||
| `FAIL: no eligible marker...` | 首批积压无合格标记,收发未验证 |
|
||||
| 其他 `FAIL` 或非零退出 | 设置、构建、网络、停止或清理失败,不算通过 |
|
||||
|
||||
启动器只转发固定结果码,抑制可能包含 Token URL 的 Electron 输出,并强制 TLS 校验。成功退出要求对应检查完成且子进程正常退出。
|
||||
|
||||
## 独立 Node 探针
|
||||
|
||||
默认只调用 `getMe` 和 `getWebhookInfo`,不读取消息或发送回复。添加 `--listen` 后,在无 webhook 的情况下进行约 45 秒长轮询,仅对运行开始后收到的私聊 `/goodbuddy_test` 回复一条固定测试文字,然后退出。不处理其他消息、不调用模型、不下载附件。
|
||||
|
||||
脚本不推进更新 offset、不修改订阅类型、不删除 webhook。已读取的更新仍可能被后续消费者再次读取;请使用专用 Bot,避免与其他轮询进程同时运行。积压达到单批 100 条时停止测试,不主动确认或清空消息。固定回复发生网络结果不明时不自动重试。
|
||||
|
||||
### 运行方法
|
||||
|
||||
历史实测环境为 Windows、PowerShell 5.1、Node.js 24.21.0。以下命令从仓库根目录执行,Token 通过隐藏输入传入当前进程环境,不保存在文件中。不要把实际 Token 写进命令、文档或 Git。
|
||||
|
||||
```powershell
|
||||
$telegramProbeSecret = Read-Host 'Telegram Bot Token' -AsSecureString
|
||||
$env:TELEGRAM_BOT_TOKEN = [System.Net.NetworkCredential]::new('', $telegramProbeSecret).Password
|
||||
try {
|
||||
node --use-env-proxy "docs/features/telegram-channel/telegram-probe.mjs"
|
||||
} finally {
|
||||
Remove-Item Env:TELEGRAM_BOT_TOKEN -ErrorAction SilentlyContinue
|
||||
$telegramProbeSecret = $null
|
||||
}
|
||||
```
|
||||
|
||||
`--use-env-proxy` 需要支持该选项的 Node 版本,脚本不会自动读取 Windows 系统代理。若机器依赖系统代理,先按下面方式将当前代理临时提供给 Node;这不会修改操作系统代理配置。结束后恢复原有环境值。
|
||||
|
||||
```powershell
|
||||
$telegramProbePreviousProxy = $env:HTTPS_PROXY
|
||||
$telegramProbeProxy = [System.Net.WebRequest]::GetSystemWebProxy().GetProxy([Uri]'https://api.telegram.org')
|
||||
if ($telegramProbeProxy.Host -ne 'api.telegram.org') {
|
||||
$env:HTTPS_PROXY = $telegramProbeProxy.AbsoluteUri
|
||||
}
|
||||
# 在此执行上面的凭据输入和测试命令。
|
||||
$env:HTTPS_PROXY = $telegramProbePreviousProxy
|
||||
```
|
||||
|
||||
测试真实收发时,将运行命令改为:
|
||||
|
||||
```powershell
|
||||
node --use-env-proxy "docs/features/telegram-channel/telegram-probe.mjs" --listen
|
||||
```
|
||||
|
||||
看到提示后,在 Telegram 打开测试 Bot 的普通私聊,发送 `/goodbuddy_test`。脚本成功发送固定回复后输出 `privateMessageRoundTrip` 的 `ok: true`。用户还需在手机端确认实际收到回复。
|
||||
|
||||
如果已发送测试指令,使用 `--pending` 替代 `--listen`。脚本单次查询排队消息,选择最近一小时内最新的私聊 `/goodbuddy_test` 并回复,无需在监听窗口内发送。该选项同样不推进 offset,重复运行可能再次回复同一条指令,仅在需要复测时使用。
|
||||
|
||||
脚本强制开启 TLS 证书校验,不沿用外部环境中的关闭校验设置。输出仅包含检查结果、Bot 用户名、是否配置 webhook、积压数量和有界错误码;不打印 Token、完整请求地址、原始异常、消息正文或聊天 ID。
|
||||
|
||||
### 结果解释
|
||||
|
||||
| 输出或退出码 | 含义 |
|
||||
| --- | --- |
|
||||
| 退出码 0,默认检查 | Bot 身份及 webhook 状态查询成功;未验证收发 |
|
||||
| 退出码 0,`privateMessageRoundTrip` 成功 | 收到测试指令,发送回复 API 返回成功 |
|
||||
| 退出码 2,`no_matching_message` | 时间窗口内未收到符合条件的测试指令,收发待验证 |
|
||||
| 退出码 1 | 网络、凭据、平台错误或测试条件不满足 |
|
||||
| `UND_ERR_CONNECT_TIMEOUT` | 网络连接超时,优先检查当前进程的代理路径 |
|
||||
| API error 401 | 凭据无效或已撤销 |
|
||||
| API error 409 | 检查 webhook 或其他长轮询实例是否占用 |
|
||||
| API error 429 | 平台限流;脚本报告等待时间,不自动继续请求 |
|
||||
|
||||
日期化实测结果见[进度记录](./progress.md)。独立 Node 请求走通不能证明 Electron 生产网络路径走通;固定回复成功也不能证明模型或工具任务成功。
|
||||
@@ -0,0 +1,134 @@
|
||||
import { app, net } from 'electron'
|
||||
import { ChannelService } from '../../../src/main/channels/channel-service'
|
||||
import { TelegramChannelDriver } from '../../../src/main/channels/telegram-channel-driver'
|
||||
|
||||
type Update = {
|
||||
update_id: number
|
||||
message?: {
|
||||
date: number
|
||||
text?: string
|
||||
from?: { id: number; is_bot: boolean }
|
||||
chat: { id: number; type: string }
|
||||
message_thread_id?: number
|
||||
is_topic_message?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function report(code: string): void {
|
||||
process.send?.(code)
|
||||
}
|
||||
|
||||
async function bounded<T>(operation: Promise<T>, ms: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Probe timeout')), ms)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let driver: TelegramChannelDriver | undefined
|
||||
let service: ChannelService | undefined
|
||||
let exitCode = 1
|
||||
try {
|
||||
const userData = process.env.GOODBUDDY_PROBE_USER_DATA
|
||||
const secret = process.env.TELEGRAM_BOT_TOKEN
|
||||
if (!userData || !secret || !process.send) throw new Error('Runner required')
|
||||
app.setPath('userData', userData)
|
||||
app.setPath('sessionData', userData)
|
||||
app.setPath('logs', userData)
|
||||
// Keep Chromium caches and diagnostics inside the disposable profile.
|
||||
app.commandLine.appendSwitch('disable-logging')
|
||||
app.commandLine.appendSwitch('disable-http-cache')
|
||||
await bounded(app.whenReady(), 15_000)
|
||||
|
||||
let selected: Update | undefined = undefined
|
||||
const electronFetch: typeof globalThis.fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input)
|
||||
const response = await net.fetch(url, init)
|
||||
if (!selected || !url.endsWith('/getUpdates') || !response.ok) return response
|
||||
const data = await response.json() as { ok?: boolean; result?: Update[] }
|
||||
if (data.ok !== true || !Array.isArray(data.result)) throw new Error('Invalid updates')
|
||||
// This is the only network adaptation. Receiving, parsing, queuing and sending
|
||||
// still run through production code; other queued commands cannot send help.
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
result: data.result.filter((update) =>
|
||||
update.update_id === selected?.update_id &&
|
||||
update.message?.text === '/goodbuddy_test' &&
|
||||
update.message?.from?.id === selected?.message?.from?.id &&
|
||||
update.message?.chat.id === selected?.message?.chat.id)
|
||||
}), { headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
driver = new TelegramChannelDriver({ secret, allowedSenderIds: [], fetch: electronFetch })
|
||||
await bounded(driver.testConnection(), 35_000)
|
||||
report('connection')
|
||||
if (!process.argv.includes('--send-test')) {
|
||||
exitCode = 0
|
||||
return
|
||||
}
|
||||
|
||||
// No offset: discovery does not acknowledge the queue or alter allowed_updates.
|
||||
const response = await net.fetch(`https://api.telegram.org/bot${secret}/getUpdates`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ timeout: 0, limit: 100 }), redirect: 'error',
|
||||
signal: AbortSignal.timeout(15_000)
|
||||
})
|
||||
const data = await response.json() as { ok?: boolean; result?: Update[] }
|
||||
if (!response.ok || data.ok !== true || !Array.isArray(data.result)) throw new Error('Discovery failed')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
selected = data.result.filter((update) => {
|
||||
const message = update.message
|
||||
return Number.isSafeInteger(update.update_id) && update.update_id >= 0 &&
|
||||
message?.text === '/goodbuddy_test' && message.chat?.type === 'private' &&
|
||||
Number.isSafeInteger(message.from?.id) && message.from!.id > 0 &&
|
||||
message.from?.is_bot === false && message.chat.id === message.from.id &&
|
||||
message.message_thread_id === undefined && !message.is_topic_message &&
|
||||
Number.isSafeInteger(message.date) && message.date >= now - 86_400 && message.date <= now
|
||||
}).sort((a, b) => b.message!.date - a.message!.date || b.update_id - a.update_id)[0]
|
||||
if (!selected) {
|
||||
report('missing')
|
||||
return
|
||||
}
|
||||
report('selected')
|
||||
const allowedSenderIds = [String(selected.message!.from!.id)]
|
||||
driver.updateAllowedSenderIds(allowedSenderIds)
|
||||
let delivered!: () => void
|
||||
const delivery = new Promise<void>((resolve) => { delivered = resolve })
|
||||
service = new ChannelService(driver, async (message) => {
|
||||
if (message.eventId !== String(selected!.update_id) || message.text !== '/goodbuddy_test') {
|
||||
throw new Error('Unexpected event')
|
||||
}
|
||||
return {
|
||||
status: 'completed',
|
||||
output: 'GoodBuddy Telegram channel test passed using the production driver and ChannelService. No AI model or tools were used.'
|
||||
}
|
||||
}, { allowedSenderIds, maximumConcurrency: 1, onDeliverySuccess: delivered })
|
||||
await service.start()
|
||||
await bounded(delivery, 45_000)
|
||||
report('delivered')
|
||||
exitCode = 0
|
||||
} catch {
|
||||
report('failed')
|
||||
} finally {
|
||||
try {
|
||||
await bounded(Promise.all([service?.stop(), driver?.stop()]), 5_000)
|
||||
} catch {
|
||||
report('shutdown')
|
||||
exitCode = 1
|
||||
}
|
||||
process.exitCode = exitCode
|
||||
app.quit()
|
||||
setTimeout(() => app.exit(exitCode), 1_000).unref()
|
||||
}
|
||||
}
|
||||
|
||||
process.on('uncaughtException', () => { report('failed'); app.exit(1) })
|
||||
process.on('unhandledRejection', () => { report('failed'); app.exit(1) })
|
||||
void main()
|
||||
@@ -4,20 +4,20 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 草案,待评审;Telegram 通道尚未实现 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-10-04 |
|
||||
| 状态 | 已按生产实现修订;真实 Bot 与模型完整链路待验收 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-10-05 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 首版范围 | 单 Bot、普通私聊、文字问答与工具任务、最终结果回复 |
|
||||
| 接入方式 | 用户通过 BotFather 创建 Bot,GoodBuddy 使用 Bot API 长轮询 |
|
||||
|
||||
本文定义拟交付的产品行为。验收表描述未来交付标准,不代表已经通过测试。BotFather 接入方向来自本次讨论;范围取舍和异常行为是本草案提出的方案。
|
||||
本文定义首版产品行为和验收标准,已按当前实现修订。验收表不代表已经通过测试;验证结果及剩余工作由[进度记录](./progress.md)维护,模块与协议细节见[技术设计](./technical-design.md)。
|
||||
|
||||
## 1. 用户问题与目标
|
||||
|
||||
用户离开电脑后,希望通过 Telegram 向 GoodBuddy 提问或发起任务,并在手机上收到结果。任务应使用桌面端已经配置的模型、Runtime 和工作目录,执行过程与对话历史仍可在 GoodBuddy 查看。
|
||||
|
||||
当前远程消息通道已有微信 ClawBot、企业微信和钉钉,尚无 Telegram 入口。新增通道应沿用现有“通道项目 + 远程会话”结构,让用户通过创建 Bot、填写凭据和配置允许访问的用户完成接入,无需自行部署消息中转服务。
|
||||
Telegram 沿用现有“通道项目 + 远程会话”结构,与微信 ClawBot、企业微信和钉钉并列。用户通过创建 Bot、填写凭据和配置允许访问的用户完成接入,无需自行部署消息中转服务。
|
||||
|
||||
首版完成以下使用流程:
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
| Bot 数量 | 每个 GoodBuddy 用户配置一个 Telegram Bot |
|
||||
| 会话类型 | 普通私聊;不同用户的会话相互隔离 |
|
||||
| 输入 | 文字消息和受支持的命令 |
|
||||
| 执行 | 复用渠道项目的处理后端和统一执行规则,无产品模式 |
|
||||
| 执行 | 复用渠道项目的处理后端、已启用能力和统一执行规则 |
|
||||
| 输出 | 任务进行提示、最终文字结果、长回复分段 |
|
||||
| 管理 | Token 保存和替换、用户 ID 白名单、连接测试、启停和状态展示 |
|
||||
| 故障处理 | 临时断网重连、平台限流等待、已保存结果的有限重试 |
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
1. 用户在 GoodBuddy 远程通道设置中打开 Telegram 卡片,点击官方 BotFather 链接。
|
||||
2. 用户在 BotFather 发送 `/newbot`,设置显示名称和符合 Telegram 要求的用户名,获得 Token。
|
||||
3. 用户回到 GoodBuddy 填写 Token,执行“测试连接”。成功后显示 Bot 名称、用户名和打开 Bot 的链接。
|
||||
3. 用户回到 GoodBuddy 填写 Token,执行“测试连接”。成功后显示 API 返回的 Bot 用户名和打开 Bot 的链接,不展示 Bot 显示名称。
|
||||
4. 用户先保存凭据并以空白名单启用连接,卡片显示“尚未授权用户”。此时仅提供私聊帮助,不接受业务请求。
|
||||
5. 用户在 Telegram 打开该 Bot,发送 `/start`,再发送 `/whoami` 获取自己的数字用户 ID。卡片说明必须由用户先发起私聊。
|
||||
6. 用户将该 ID 填入 GoodBuddy 白名单并保存,随后发送普通文本,收到回复,并在 Telegram 项目中看到对应会话。
|
||||
@@ -82,9 +82,9 @@
|
||||
|
||||
### FR-2 凭据管理
|
||||
|
||||
Token 使用现有加密凭据存储,保存后不在普通设置快照中返回明文。用户可以保留、替换或清除凭据。日志、连接错误和会话内容不得包含 Token 或含 Token 的完整请求地址。
|
||||
Token 使用现有加密凭据存储,保存后不在普通设置快照中返回明文。用户可以保留、替换或清除凭据;环境变量覆盖时配置只读。日志、连接错误和会话内容不得包含 Token 或含 Token 的完整请求地址。
|
||||
|
||||
替换 Token 前验证 Bot 身份。若仍为同一个 Bot,继续使用原会话;若为另一个 Bot,新会话与旧 Bot 隔离,旧 Bot 的待发结果不得经新 Bot 发送。清除凭据停止连接,保留项目与历史。
|
||||
替换 Token 前验证 Bot 身份,验证失败保留原配置与连接。若仍为同一个 Bot,继续使用原会话;若为另一个 Bot,新会话与旧 Bot 隔离,旧 Bot 的待发结果不得经新 Bot 发送。在设置页清除凭据并保存时同时停用通道,保留项目与历史。
|
||||
|
||||
### FR-3 连接测试
|
||||
|
||||
@@ -102,14 +102,15 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
|
||||
|
||||
### FR-5 会话与连续上下文
|
||||
|
||||
同一 Bot 下同一普通私聊复用远程会话,不同用户和不同 Bot 的上下文隔离。会话显示来源和发送者名称;名称不可用时使用用户 ID 作为可识别的回退信息。名称变化不产生新身份。
|
||||
同一 Bot 下同一普通私聊复用远程会话,不同用户和不同 Bot 的上下文隔离。会话归属 Telegram 项目,发送者以数字用户 ID 标识;首版不采集发送者显示名称。
|
||||
|
||||
同一会话按接收顺序处理业务消息。用户连续发送消息时,不得使后发请求抢用尚未完成的上一轮上下文。不同会话沿用现有通道并发限制。
|
||||
|
||||
### FR-6 执行范围
|
||||
|
||||
普通文本使用项目后端与能力配置;`/ask`、`/execute` 和 `/exec` 沿用现有渠道的退役规则,
|
||||
作为普通正文保留,不切换权限。仅含旧命令的非空消息也按普通消息处理。
|
||||
授权用户直接发送自然语言请求,使用项目后端与能力配置完成问答或工具任务,无需选择或切换工作模式。通道不提供模式选择器、默认模式设置或模式切换命令。
|
||||
|
||||
`/ask`、`/execute` 和 `/exec` 按现有统一执行规则作为普通正文保留,不剥离前缀、不切换权限;仅含旧命令的非空消息也按普通消息处理。帮助文案不再介绍这些前缀。
|
||||
|
||||
按[统一执行规则](../unified-execution/logic-design.md)使用已启用工具,不增加 Telegram 专属
|
||||
逐工具审批。设置页显示默认工作目录和后端,并说明允许访问的用户可发起操作。
|
||||
@@ -119,7 +120,7 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
|
||||
|
||||
`/start` 返回 Bot 用途、授权配置说明和基础用法;`/whoami` 仅返回当前发送者的数字用户 ID。上述命令不写入业务上下文。
|
||||
|
||||
授权用户发送未知斜杠命令时,返回支持的命令列表,不将其当成执行请求。编辑历史消息不自动重新执行任务。
|
||||
除 `/start` 和 `/whoami` 外,授权用户发送的非空文字均按普通请求处理,包括带斜杠前缀的文字,不额外引入命令兼容规则。空白消息按现有输入规则拒绝。编辑历史消息不自动重新执行任务。
|
||||
|
||||
授权用户发送媒体时,提示“当前仅支持文字消息,请改用文字描述”。带说明文字的媒体首版同样提示不支持,不丢弃媒体后擅自执行说明文字。未授权媒体不触发处理。
|
||||
|
||||
@@ -127,7 +128,7 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
|
||||
|
||||
请求进入处理后,Telegram 侧显示输入状态或简短进行提示。该提示不表示任务完成,也不表示任务可以在应用崩溃后恢复。
|
||||
|
||||
任务完成后发送最终文字;失败时发送脱敏原因及必要的恢复建议。回复尽量关联原始消息;原消息不可引用时仍应尝试发送结果。长文本按平台上限顺序分段,不破坏 Unicode 字符,不因 Markdown 转义问题造成整条回复失败。
|
||||
任务完成后发送最终文字;失败时发送脱敏原因及必要的恢复建议。回复作为普通私聊消息发送,首版不引用原始消息。长文本以纯文本顺序分段,保留 Unicode 代理对,不启用 Markdown 解析。
|
||||
|
||||
首版不发送附件。若任务产生图片或文件,文字回复说明成果可在 GoodBuddy 中查看,不宣称附件已发送,也不向用户发送仅本机可访问的路径作为下载链接。
|
||||
|
||||
@@ -137,7 +138,9 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
|
||||
|
||||
已生成并保存的结果沿用现有发件箱有限重试,发送失败与任务执行失败分别记录。补发回复不重新调用模型或重跑任务。用户阻止 Bot 等不可恢复错误应停止无效重试。
|
||||
|
||||
多段回复部分成功后,应尽量从未发送部分继续。网络中断导致无法确定平台是否收到某段时,允许重试出现重复段落,首版不承诺平台级发送幂等。
|
||||
已有 webhook 或另一实例占用轮询时,已确认 Bot 身份且目标匹配的待发回复仍可发送;接收冲突本身不能永久终止这些回复。发送内部重试达到上限后,发件箱的下一次尝试仍须遵守最近一次 429 响应的等待时间,停用可取消等待。
|
||||
|
||||
同一驱动实例内且分段进度仍在缓存时,多段回复从未发送部分继续。进度不跨重启持久化,重启或缓存淘汰后补发可能重复已发送段落;网络中断造成平台接收结果不明时也可能重复。首版不承诺平台级发送幂等。
|
||||
|
||||
### FR-10 启停与历史保留
|
||||
|
||||
@@ -157,7 +160,11 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
|
||||
|
||||
### FR-12 状态与诊断
|
||||
|
||||
设置页区分未配置、已停用、正在连接、已连接、正在重连和连接失败。空白名单作为“尚未授权用户”的配置提示展示,不与网络状态混为一谈。
|
||||
设置页展示未配置、已停用、正在连接、已连接、已停止和连接失败。重连时显示正在连接,并附带错误原因与重连说明;没有独立的重连状态值。空白名单作为“尚未授权用户”的配置提示展示,不与网络状态混为一谈。
|
||||
|
||||
状态和错误推送只更新状态区域,不覆盖尚未保存的 Token 或白名单草稿。解决轮询冲突后可重新保存当前配置以启动连接;单独测试连接成功不改变接收状态。
|
||||
|
||||
接收正常而结果发送失败时,保留已连接状态并显示脱敏的发送错误。后续健康轮询不清除发送提示,投递成功后清除;接收也发生错误时优先展示接收原因,恢复后保留尚未清除的发送提示。
|
||||
|
||||
状态通过文字表达,不能只靠颜色。错误保留可操作原因,如 Token 无效、网络不可达、webhook 占用或另一实例正在轮询。测试成功、发送失败和模型失败使用各自准确文案,避免统一显示“连接失败”。
|
||||
|
||||
@@ -174,15 +181,15 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
|
||||
| AC-03 | FR-2、3 | 通道运行时连续测试连接,不争抢轮询、不消费业务消息;无效 Token 与 webhook 占用分别提示 |
|
||||
| AC-04 | FR-5 | 同一用户连续两轮对话保留上下文;两名授权用户的内容不相互进入上下文 |
|
||||
| AC-05 | FR-6 | 普通请求可在专用测试目录完成一次无害工具任务;旧模式前缀作为正文保留,结果和活动可在桌面核对,无默认模式字段 |
|
||||
| AC-06 | FR-4、7 | 未授权用户、群消息、话题消息和媒体不触发任务;授权媒体、未知命令和空命令获得准确提示 |
|
||||
| AC-06 | FR-4、7 | 未授权用户、群消息、话题消息和媒体不触发任务;授权媒体获得不支持提示;内置帮助命令不调用模型,其他斜杠前缀按正文保留,空白消息被拒绝 |
|
||||
| AC-07 | FR-8 | 超过单条平台上限的回复按序完整展示,中文和表情字符完整;任务失败返回脱敏原因 |
|
||||
| AC-08 | FR-8 | 生成文件的任务在 Telegram 中提示桌面查看成果,不出现“附件已发送”的错误表述 |
|
||||
| AC-09 | FR-9、12 | 模拟短暂断网、429 和永久发送错误,分别重连、等待和停止无效重试;任务执行结果不被误记为执行失败 |
|
||||
| AC-10 | FR-9 | 已保存结果发送失败后恢复连接可补发,补发不增加模型调用或再次执行任务;验证部分分段成功情形 |
|
||||
| AC-11 | FR-10 | 停用通道后新消息不启动任务,当前与排队任务取消;历史与成果保留;重新启用不重跑取消任务 |
|
||||
| AC-11 | FR-2、10 | 停用通道后新消息不启动任务,当前与排队任务取消;启用时清除 Token 并保存会同时停用;项目、历史与成果保留;重新启用不重跑取消任务 |
|
||||
| AC-12 | FR-2、5 | 同一 Bot 换 Token 保留会话;更换 Bot 后上下文隔离,旧待发结果不会由新 Bot 发出 |
|
||||
| AC-13 | FR-4、11 | 重投同一更新不重复执行;离线后恢复可接收平台保留消息;已撤销用户的积压消息不执行 |
|
||||
| AC-14 | FR-2、12 | 设置快照、日志及错误不泄漏 Token;凭据轮换、网络错误和 Runtime 错误均可定位 |
|
||||
| AC-14 | FR-2、12 | 设置快照、日志及错误不泄漏 Token;凭据轮换、网络错误和 Runtime 错误均可定位;连接错误及重连状态推送保留未保存的 Token 和白名单草稿 |
|
||||
| AC-15 | FR-1、10 | 新增 Telegram 后微信、企业微信和钉钉的设置、启停与历史读取保持原行为 |
|
||||
| AC-16 | FR-9、10 | 有已保存回复等待重试时停用通道,确认重试停止;重新启用后补发该 Bot 的结果,任务执行次数不增加 |
|
||||
| AC-17 | FR-11 | 在专用测试任务执行期间异常退出应用,重启后不自动重跑该中断任务;已有任务记录可查询,不把未取得的完成结果显示为成功;帮助说明保留人工核对后重发的限制 |
|
||||
@@ -193,9 +200,9 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
|
||||
|
||||
通道项目、工作目录、Runtime 选择和授权语义复用[现有远程消息通道约定](../wechat-channel/prd.md)。Telegram 的外部网络可达性、Bot 状态和平台限额影响收发,连接测试无法证明后续每次任务或文件生成都能成功。
|
||||
|
||||
开发前需核对主进程网络与代理路径,以及现有通道停用、去重和发件箱行为是否满足本文要求。差异应在对应技术设计中解决并关联 FR 编号,不能只增加设置入口后宣称完成。本文不规定新增 SDK、数据库表或通用恢复框架。
|
||||
生产驱动使用 Electron 网络栈和系统代理。停用、去重、发件箱与状态推送的实现见[技术设计](./technical-design.md),实际覆盖与未完成验证见[进度记录](./progress.md)。没有测量数据时,不宣称性能提升或性能预算通过。
|
||||
|
||||
前期讨论提出过“持久化入站后再确认”的方案。本草案将其列为后续可靠性评估,首版采用 FR-11 的明确限制;若后续要求已接收任务跨崩溃恢复,再依据实际故障与业务需求评估存储和执行恢复成本。
|
||||
持久化入站后再确认仍属于后续可靠性评估,首版采用 FR-11 的限制;若后续要求已接收任务跨崩溃恢复,再依据实际故障与业务需求评估存储和执行恢复成本。
|
||||
|
||||
| 后续方向 | 进入设计前需明确的内容 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Telegram 实施与验证进度
|
||||
|
||||
## 2026-10-05 当前结果
|
||||
|
||||
生产设置入口、驱动、项目路由、执行、持久化和状态推送已接入,行为见[技术设计](./technical-design.md)。真实 Telegram Bot 与模型的完整链路尚未证明,[PRD 验收](./prd.md#5-产品验收)仍待完成。
|
||||
|
||||
以下计数来自本次开发会话提供的验证结果,本轮文档修订未重跑这些测试。集合可能重叠,不相加计算覆盖总数。
|
||||
|
||||
| 验证范围 | 结果 | 证据边界 |
|
||||
| --- | --- | --- |
|
||||
| Driver / Service / Manager 聚焦测试 | 74 通过 | 解析、授权、重试、身份隔离与生命周期等自动化覆盖 |
|
||||
| 集成测试集合 | 251 通过,1 跳过 | 包含 Electron 生产 UI / IPC / store / executor 与本地模型 fixture,Telegram 网络响应为替身 |
|
||||
| Electron 场景 | 2 次本地模型调用,0 次外部调用 | 配置测试与加密保存、授权和去重、结果入库与回复、重载恢复、停用取消;错误及重连状态推送保留草稿;启用状态清除 Token 后停止连接并保留项目和历史 |
|
||||
| Typecheck / lint | 最终复跑均通过 | 静态检查,不能替代真实 Bot 联调 |
|
||||
| 全量测试原始结果 | 6086 通过,86 跳过,2 失败 | 保留当次失败记录;后续修复与复跑见下两行,尚无新的全量通过结论 |
|
||||
| Schema 断言修复 | 已改用 schema 版本常量;四个 suite 共 301 通过 | 修复原先预期 60、实际 61 的 worker 断言,不将局部复跑计为全量通过 |
|
||||
| OpenCode cleanup-timeout 复跑 | 单独运行 3 次通过,整个 runtime suite 通过 | 原因尚未证明,记录为全量运行中出现的间歇性失败,不宣称已修复 |
|
||||
| 最终全量测试 | 6087 通过,87 跳过,1 失败;耗时 879.68 秒 | 唯一失败为 `tests/inline-help.electron.test.ts` 的 Hover Escape 场景;原样单独复跑通过,仍保留间歇性失败记录。此前 schema 和 Runtime 失败未在本轮出现 |
|
||||
| 生产实时探针 | 本次未运行 | 当前会话缺少 `TELEGRAM_BOT_TOKEN` |
|
||||
| 生产探针 dry-run | 打包与语法检查通过 | 未启动 Electron,未发出网络请求 |
|
||||
| 真实本地代理 fixture | 真实 Electron 生产默认传输曾成功运行 1 次;最终运行因临时 CA 信任导入超时失败 | 后续 Windows 临时 CA 信任导入需要操作系统确认;测试已改为显式启用,默认跳过。不宣称可无人值守复现 |
|
||||
| Main 性能测量 | 基线与当前版本交替各运行 3 轮 | 结果见下节;没有证据证明性能改善或 Telegram 引入回退,不宣称全局预算通过 |
|
||||
|
||||
Electron 证据对应 [`tests/telegram-channel.electron.test.ts`](../../../tests/telegram-channel.electron.test.ts) 与 [`tests/support/telegram-main.ts`](../../../tests/support/telegram-main.ts)。它使用生产组件、Preload、IPC、配置存储、executor 和 SQLite;在 `net.fetch` 边界替换 Telegram 响应,模型为本地 HTTP fixture。因此它验证内部产品路径,不能证明外部 Telegram 网络、系统代理或真实模型服务可用。
|
||||
|
||||
可复跑的聚焦命令:
|
||||
|
||||
```powershell
|
||||
npx vitest run src/main/channels/telegram-channel-driver.test.ts src/main/channels/channel-service.test.ts src/main/channels/channel-manager.test.ts
|
||||
npx vitest run tests/telegram-channel.electron.test.ts
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
集成集合及 301 项复跑的完整命令未随计数提供,这里不推测其文件清单。真实本地代理 fixture 见 [`tests/telegram-proxy.electron.test.ts`](../../../tests/telegram-proxy.electron.test.ts);其本地代理结果不证明外部 Telegram Bot 可用。探针操作见 [API 联调](./api-testing.md),探针成功也不包含模型验收。
|
||||
|
||||
## 2026-10-05 通道审计修复验证
|
||||
|
||||
A06 的图片结果回归使用现有 IPC executor 图片事件 fixture,接入生产 `ChannelService` 和内存 SQLite 发件箱。修复前回复状态为 `failed`;修复后状态为 `completed`,图片附件字节保持一致,桌面会话仍记录成果 ID。未调用付费图片模型。
|
||||
|
||||
A07 的 Renderer 回归覆盖推送状态为 `error`、`stopped`、`running`、`starting`、`disabled` 的无修改保存。前两种状态在修复前没有提交配置,修复后能够提交;其余状态保持不提交。扩展的 Electron 场景通过真实设置 UI、Preload、IPC、Manager 和驱动验证 409 后无修改保存恢复轮询,以及正常连接时再次保存不重启。Telegram API 在网络边界使用替身;本次运行有 2 次本地 HTTP 模型调用、0 次外部请求。
|
||||
|
||||
A08 的钉钉回归使用生产传输、服务和 SQLite 发件箱,HTTP 请求只发送到 loopback fixture。首次 HTTP 200 携带非零 `errcode`,修复前被当作成功;修复后记录一次失败并保留上下文,第二次成功响应完成投递,executor 只执行一次。另有空响应、缺少错误码、JSON null、无效 JSON 和超限响应回归。
|
||||
|
||||
本轮聚焦验证:6 个 channel / Renderer suite 共 106 项通过;IPC 图片、文件与 Telegram 场景 3 项通过;Electron 场景 1 项通过。修复前选择性回归为 9 项失败、3 项通过。命令如下:
|
||||
|
||||
```powershell
|
||||
npm test -- src/main/channels/dingtalk-channel-driver.test.ts src/renderer/src/ChannelSettingsSection.test.tsx src/main/channels/channel-manager.test.ts src/main/channels/channel-service.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/sqlite-channel-state.test.ts
|
||||
npm test -- src/main/ipc.test.ts -t "returns a generated image only|creates a bounded result file|routes Telegram settings"
|
||||
npm test -- tests/telegram-channel.electron.test.ts
|
||||
```
|
||||
|
||||
这些结果验证本地生产连接关系,不替代真实平台收发或图片模型验收。本轮未改 Agent、Runtime 协议或执行策略,未做性能改善声明。
|
||||
|
||||
补充检查:钉钉错误码断言加入后,单独复跑 9 项通过;修改的 TS 文件 ESLint、Renderer typecheck 和 `git diff --check` 通过。早期全仓检查受到并行修改中的类型与 lint 错误阻塞,负责方修正后最终检查均通过;完整测试为 6,136 项通过、0 失败、87 跳过。汇总见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。
|
||||
|
||||
## 2026-10-05 K09 投递修复
|
||||
|
||||
本轮只修改 Telegram 驱动、Manager 及其测试,复用现有服务重试、SQLite 发件箱和状态 IPC。未改数据库结构、Main IPC、Runtime 或 Agent;本地与远程任务完成后的通道结果均使用同一发送路径,无需独立 Agent 修复。
|
||||
|
||||
修复前先运行持久回归:`telegram-delivery.test.ts` 的 3 项 loopback HTTP/SQLite 用例和 Manager 的状态用例共 4 项失败。启动 webhook 冲突时,同 Bot 的待发回复未发出且变为终止记录;403 后状态仍为 `running` 且无错误;连续 429 的最后响应要求等待 3 秒,下一次发送却只间隔约 1.010 秒。扩展的 Electron 设置 fixture 也在“已连接时显示发送失败”断言处失败。
|
||||
|
||||
修复后,启动阶段保留 `getMe` 确认的身份,已有 webhook 时发送同 Bot 的待发回复,其他 Bot 的记录仍被拒绝,轮询状态仍报告冲突。发送错误经脱敏后由现有状态区域显示,健康轮询不清除;发送成功清除投递提示但不覆盖轮询错误。驱动在内部重试耗尽后仍完成最后一次 `retry_after` 等待,再返回发件箱;新增取消用例验证等待可中断、记录保持 `pending / attempts=0`,重新启用后只补发一次且不运行 executor。
|
||||
|
||||
| 检查 | 最终结果 |
|
||||
| --- | --- |
|
||||
| 9 个通道/Renderer 聚焦文件 | 127 项通过,包含 4 项 loopback HTTP/SQLite 回归 |
|
||||
| 现有 Electron 设置 fixture | 1 项通过;生产设置 UI、Preload、IPC、Manager、驱动与 SQLite,403 提示、已连接状态、Token/白名单草稿、重载恢复均通过 |
|
||||
| 修改的 6 个 TS 文件 ESLint | 通过 |
|
||||
| `npm run typecheck` | Main、Agent 通过;Renderer 被并行工作区的 `App.tsx:174` 未使用 `refreshArtifacts` 导入阻塞,未修改该文件 |
|
||||
|
||||
最终聚焦命令:
|
||||
|
||||
```powershell
|
||||
npm exec -- vitest run src/main/channels/telegram-delivery.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/channel-manager.test.ts src/main/channels/channel-service.test.ts src/main/channels/channel-settings-store.test.ts src/main/channels/sqlite-channel-state.test.ts src/renderer/src/ChannelSettingsSection.test.tsx src/renderer/src/channel-status-store.test.ts src/main/channels/dingtalk-channel-driver.test.ts
|
||||
npm exec -- vitest run tests/telegram-channel.electron.test.ts
|
||||
npm exec -- eslint src/main/channels/telegram-channel-driver.ts src/main/channels/channel-manager.ts src/main/channels/telegram-delivery.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/channel-manager.test.ts tests/support/telegram-main.ts
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Electron 最终运行使用 2 次本地 HTTP 模型响应;连同修复前失败运行和中间验证,本轮共 6 次本地 fixture 请求、0 次外部 Provider 请求、0 次真实 Telegram 收件人消息。未读取 `.env`,未运行生产构建。完整测试、全仓 lint 及合并后的类型检查由协调方执行;本节不声明全仓验收通过或性能提升。
|
||||
|
||||
## Main 性能对比
|
||||
|
||||
基线为 `78afec77758f3c175f8e2b80e5be4327e13b4689` 的临时 detached worktree,当前为包含 Telegram 改动的工作区。按基线、当前交替执行 `npm run perf:main` 各 3 次。环境为 Windows x64、Intel Core Ultra X7 358H、Electron 44.5.1、Node 24.21.0;每轮使用隔离数据库,包含 1000 个会话、20008 条生成消息、24000 条活动记录,每个场景预热后测量 15 次,无外部模型调用。
|
||||
|
||||
下表为每轮调用耗时 p95,单位为毫秒。对比相同生产路径,不将脚本内部的历史同步算法视为基线版本。
|
||||
|
||||
| 场景 | 基线三轮 | 当前三轮 |
|
||||
| --- | --- | --- |
|
||||
| 会话摘要 worker | 25.0 / 20.9 / 29.3 | 18.8 / 26.6 / 53.3 |
|
||||
| 最大会话 worker | 1.1 / 0.6 / 1.1 | 0.8 / 1.0 / 1.2 |
|
||||
| 活动增量更新 | 1.3 / 2.3 / 2.0 | 1.3 / 1.5 / 1.7 |
|
||||
| 活动启动 worker | 17.7 / 18.7 / 29.2 | 13.7 / 23.0 / 25.2 |
|
||||
| 整份活动替换,一条变化 | 61.8 / 84.9 / 73.1 | 52.0 / 84.0 / 73.8 |
|
||||
|
||||
worker 读取、活动启动、增量更新和同步首页查询在六轮中均满足 Main 阻塞与事件循环延迟预算;当前这些路径的最大循环间隔为 3.7 ms,最高循环延迟 p99 为 4.4 ms。整份活动替换在两个版本中均超预算,当前最大循环间隔为 47.0–84.2 ms;同步活动汇总也存在超过 16 ms 的情况。这些已有路径不属于本次 Telegram 优化范围。
|
||||
|
||||
版本差异与轮次波动交叠,不能据此归因于 Telegram 或声称性能提升。测量在新数据库初始化后进行,不覆盖已填充 schema 60 数据库升级到 61 的耗时、Telegram 流量或完整 UI 响应。原始结果保存在本次机器临时目录 `telegram-main-results-20261005-a`,不作为仓库依赖。
|
||||
|
||||
## 2026-10-04 历史 API 证据
|
||||
|
||||
独立 `telegram-probe.mjs` 在 Windows / PowerShell 5.1 / Node.js 24.21.0 下测试。首次直连曾有关闭 TLS 校验的环境配置,不作为正常 HTTPS 证据;开启校验后直连超时。将 Windows 系统代理临时传给 Node 后,`getMe`、`getWebhookInfo` 成功,无 webhook。
|
||||
|
||||
初次监听窗口未收到匹配指令;随后 `--pending` 从 2 条积压中匹配 `/goodbuddy_test`,`sendMessage` 返回成功,输出 `privateMessageRoundTrip: ok=true`。没有模型调用,手机端实际展示仍需用户确认。该记录只证明当日独立 Node API 路径,不证明当前凭据有效、生产驱动可用或完整产品验收通过。历史凭据曾公开,应撤销后换新,文档不保留 Token。
|
||||
|
||||
## 待完成
|
||||
|
||||
- 在可完成操作系统信任确认的环境中复跑显式启用的代理测试,记录最终结果;保留单次成功和后续导入超时两项证据,不把默认跳过视为通过。
|
||||
- Runtime 清理用例的 GC 生命周期问题已定位并修正测试夹具,完整集成测试通过,见[GC 复查](../unified-execution/progress.md#sdk-cleanup-timeout-复查)。历史帮助弹层间歇性失败在本轮完整测试中未出现。
|
||||
- 提供有效测试凭据后运行生产实时探针,记录连接及固定回复结果;本次缺少凭据的阻塞不以历史 Node 成功替代。
|
||||
- 从真实设置页保存并启用专用 Bot,在手机发送授权请求,经过实际模型产生回复并核对桌面历史;再按 PRD 验证连续上下文、隔离、无害工具任务和恢复场景。当前没有真实 Bot 与模型完整链路的通过记录。
|
||||
- Main 测量未覆盖迁移和完整 UI;后续验收需按[性能原则](../../architecture/performance-principles.md)区分实测路径与未测路径。
|
||||
@@ -0,0 +1,102 @@
|
||||
/* global process, console, setTimeout, clearTimeout, URL */
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const messages = {
|
||||
connection: 'PASS: production driver getMe/getWebhookInfo.',
|
||||
selected: 'Selected a queued private test marker from the last 24 hours.',
|
||||
delivered: 'PASS: production driver and ChannelService delivered the test reply.',
|
||||
missing: 'FAIL: no eligible marker in the pending batch (maximum 100 updates).',
|
||||
failed: 'FAIL: live probe failed; network details suppressed.',
|
||||
shutdown: 'FAIL: probe shutdown did not complete.'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
let tempParent = tmpdir()
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (args[i] === '--temp-parent' && args[i + 1]) tempParent = args[++i]
|
||||
else if (!['--dry-run', '--send-test'].includes(args[i])) {
|
||||
console.error('Usage: node run-live-probe.mjs [--dry-run | --send-test] [--temp-parent PATH]')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
const dry = args.includes('--dry-run')
|
||||
const send = args.includes('--send-test')
|
||||
if (dry && send) throw new Error('Invalid mode')
|
||||
if (!dry && !/^\d+:[A-Za-z0-9_-]+$/.test(process.env.TELEGRAM_BOT_TOKEN ?? '')) {
|
||||
console.error('Set TELEGRAM_BOT_TOKEN in the environment before running live.')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (!(await stat(tempParent)).isDirectory()) throw new Error('Invalid temporary parent')
|
||||
const directory = await mkdtemp(join(tempParent, 'goodbuddy-telegram-live-'))
|
||||
try {
|
||||
const outfile = join(directory, 'probe.cjs')
|
||||
await build({
|
||||
entryPoints: [fileURLToPath(new URL('./live-probe-main.ts', import.meta.url))],
|
||||
outfile, bundle: true, platform: 'node', format: 'cjs', target: 'node22',
|
||||
external: ['electron'], logLevel: 'silent'
|
||||
})
|
||||
const check = spawnSync(process.execPath, ['--check', outfile], {
|
||||
stdio: 'ignore', timeout: 15_000
|
||||
})
|
||||
if (check.status !== 0) throw new Error('Bundle syntax check failed')
|
||||
const electron = require('electron')
|
||||
if (typeof electron !== 'string') throw new Error('Electron executable unavailable')
|
||||
if (dry) {
|
||||
console.log('PASS: bundled production sources and checked syntax; Electron not launched, no network calls.')
|
||||
return
|
||||
}
|
||||
if (send) {
|
||||
console.log('Send mode: use a dedicated idle bot. Polling may consume/acknowledge earlier queued updates.')
|
||||
console.log('Only the selected exact /goodbuddy_test event receives a fixed reply; no model or tools run.')
|
||||
}
|
||||
const userData = join(directory, 'user-data')
|
||||
await mkdir(userData)
|
||||
const env = { ...process.env, GOODBUDDY_PROBE_USER_DATA: userData }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
delete env.ELECTRON_ENABLE_LOGGING
|
||||
delete env.ELECTRON_LOG_FILE
|
||||
delete env.NODE_OPTIONS
|
||||
env.NODE_TLS_REJECT_UNAUTHORIZED = '1'
|
||||
process.exitCode = await new Promise((resolve) => {
|
||||
// Never forward Electron/Chromium stderr: it may contain credential-bearing URLs.
|
||||
const child = spawn(electron, [outfile, ...(send ? ['--send-test'] : [])], {
|
||||
env, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true
|
||||
})
|
||||
let passed = false
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 125_000)
|
||||
const interrupt = () => child.kill('SIGKILL')
|
||||
process.once('SIGINT', interrupt)
|
||||
process.once('SIGTERM', interrupt)
|
||||
child.on('message', (code) => {
|
||||
if (typeof code !== 'string' || !Object.hasOwn(messages, code)) return
|
||||
console.log(messages[code])
|
||||
if (code === (send ? 'delivered' : 'connection')) passed = true
|
||||
})
|
||||
child.once('error', () => resolve(1))
|
||||
child.once('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
process.removeListener('SIGINT', interrupt)
|
||||
process.removeListener('SIGTERM', interrupt)
|
||||
if (code !== 0 || !passed) console.error('FAIL: Electron probe did not complete successfully.')
|
||||
resolve(code === 0 && passed ? 0 : 1)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
console.error('FAIL: probe setup, build, or cleanup failed; details suppressed.')
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
# Telegram 通道技术设计
|
||||
|
||||
本文对应 [PRD FR-1~12](./prd.md),记录当前生产实现。验证状态见[进度记录](./progress.md)。实现约束沿用[性能原则](../../architecture/performance-principles.md),下述结构不构成性能测量结论。
|
||||
|
||||
## 生产路径与网络
|
||||
|
||||
设置页经生产 Preload 和 IPC 调用 `ChannelManager`,由 `ChannelSettingsStore` 保存配置。Manager 按需加载 `TelegramChannelDriver`,向其注入 Electron `net.fetch`;驱动默认网络实现也使用 `net.fetch`。请求走 Electron 网络栈及系统代理,不依赖 Node 的 `HTTPS_PROXY` 配置,不使用 grammY 或 Telegraf。
|
||||
|
||||
驱动先调用 `getMe` 与 `getWebhookInfo`,已有 webhook 时拒绝启动轮询,不删除外部配置。`getMe` 验证的 Bot 身份独立保留;启动 webhook 冲突与运行中的轮询冲突均不阻止身份匹配的已保存回复发送,也不因此终止其发件箱记录。测试连接只做这两项检查,不启动轮询或改写运行中的身份。启用后使用 `getUpdates`,`timeout=30`、`limit=100`、`allowed_updates=['message']`;普通请求超时 15 秒,轮询请求超时 40 秒。临时错误以 1~30 秒退避重连,429 按 `retry_after` 等待;其他 4xx 停止轮询并报告错误。
|
||||
|
||||
## 身份、执行与保存
|
||||
|
||||
| 对象 | 实现约定 |
|
||||
| --- | --- |
|
||||
| 配置 | 单 Bot;Token 加密保存,快照只返回是否已配置;保留、替换、清除三种动作 |
|
||||
| 环境覆盖 | `GOODBUDDY_TELEGRAM_BOT_TOKEN`、`GOODBUDDY_TELEGRAM_ENABLED`、`GOODBUDDY_TELEGRAM_ALLOWED_SENDERS`;发送者为逗号分隔的数字 ID,空列表只提供帮助;配置只读,群聊不支持 |
|
||||
| 入站身份 | `accountId=Bot ID`,`conversationId=Bot ID:chat ID`,`eventId=update_id`,授权使用数字发送者 ID |
|
||||
| 执行 | 普通私聊文字经 `ChannelService` 队列进入现有远程通道 executor,使用 Telegram 项目后端与工作目录;同会话串行 |
|
||||
| 内置反馈 | `/start`、`/whoami` 和授权媒体提示由驱动直接回复,不调用模型或写业务上下文;每个用户每类反馈冷却 5 秒 |
|
||||
| 持久化 | 复用 SQLite 远程会话、去重和发件箱;schema 61 将相关 channel 约束扩展到 Telegram,保留既有数据;设置文件仍为版本 3,缺失 Telegram 配置时使用默认值 |
|
||||
|
||||
驱动只接收普通私聊,忽略群、频道、话题、编辑更新及无有效发送者的消息。媒体附带文字也不作为业务输入。驱动与服务均检查授权,白名单更新可原位应用,不取消已开始的任务;排队任务执行前再次检查授权。
|
||||
|
||||
入站进入内存队列后确认,驱动随后推进内存 offset。去重键为通道、Bot ID、事件 ID;入站队列和 offset 不持久化,不承诺崩溃后自动续跑或零丢失。重连接收平台仍保留的积压消息,工具请求也可能在重新启用后执行。
|
||||
|
||||
## 发送与生命周期
|
||||
|
||||
结果先进入现有发件箱,发送失败由服务有限重试,不重新调用模型;失败计数达到 5 或遇到永久错误后停止补发。发送前校验目标会话中的 Bot ID,拒绝把旧 Bot 结果交给新 Bot。附件只附加桌面查看提示。
|
||||
|
||||
生成图片后,executor 先把 `artifactIds` 写入桌面会话,再向 `ChannelService` 返回通道契约中的状态、正文、错误和附件。图片字节保留在附件中;桌面成果 ID 不进入严格校验的通道结果,避免成功任务被误报为“无效结果”。该转换位于本地和远程 Runtime 事件处理之后,不改变 Runtime 或 Agent 协议。
|
||||
|
||||
驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送遇到 429 最多额外尝试 3 次;最后一次仍被限流时,先等待该响应的 `retry_after`,再将失败交回发件箱,避免服务层提前重发。等待沿用调用方与服务生命周期的取消信号;停止期间不增加失败计数。跨重启补发与不确定网络结果允许产生重复段落。
|
||||
|
||||
停用会中止轮询、发送等待、活动执行与排队工作,等待活动操作结束后报告停止。清除凭据时设置页同时提交 `enabled=false`;底层拒绝启用但无 Token 的配置。Token 替换先验证,失败不停止旧服务。项目与历史不因停用或清除凭据删除。
|
||||
|
||||
## 状态与界面
|
||||
|
||||
Manager 通过 `{ channel, status }` 增量 IPC 推送状态,忽略旧服务实例的迟到事件。Telegram 连接状态由驱动维护,发送成功不会覆盖轮询错误。状态值为 `disabled`、`starting`、`running`、`stopped`、`error`;重连使用 `starting` 加错误说明,未配置由界面结合凭据状态显示。
|
||||
|
||||
Manager 在当前服务实例内保留最近一次发送错误,经既有脱敏函数处理后,附在连接状态的错误说明中。轮询正常时仍显示已连接,同时提示 `Telegram sending failed: ...`;健康轮询不会清除该提示。若同时有轮询错误,优先显示轮询原因;恢复接收后仍可看到未清除的发送错误。后续投递成功清除发送提示,停用或替换服务释放该实例的错误状态,无新增持久化记录或 IPC 字段。
|
||||
|
||||
Renderer 的 `channel-status-store.ts` 订阅事件,selector 只向状态区域提供对应通道数据。首次快照不会覆盖已收到的较新事件;状态更新不重新加载整份设置,不重置 Token 或白名单草稿。配置页复用共享页签、开关、项目设置字段和通知控件。
|
||||
|
||||
首次配置说明、空白名单提示和发送者 ID 字段帮助均明确:ID 可先留空,先启用并保存通道,连接后私聊自己的 Bot 发送 `/whoami`,取得本人数字用户 ID 后填入并再次保存。仅测试连接不启动消息接收;用户名和 Bot ID 不能代替发送者 ID。白名单为空时仍只提供帮助,不接受 AI 请求。
|
||||
|
||||
保存时读取状态 store 的最新值。Telegram 已启用、配置可编辑且状态为 `error` 或 `stopped` 时,即使字段未变,也提交当前配置以重启连接;`running`、`starting` 状态下的无修改保存不重启轮询。环境变量配置仍为只读,测试连接不改变轮询状态。
|
||||
|
||||
源码入口:[`telegram-channel-driver.ts`](../../../src/main/channels/telegram-channel-driver.ts)、[`channel-manager.ts`](../../../src/main/channels/channel-manager.ts)、[`channel-service.ts`](../../../src/main/channels/channel-service.ts)、[`channel-settings-store.ts`](../../../src/main/channels/channel-settings-store.ts)、[`channel-status-store.ts`](../../../src/renderer/src/channel-status-store.ts)、[`ChannelSettingsSection.tsx`](../../../src/renderer/src/ChannelSettingsSection.tsx)。
|
||||
@@ -0,0 +1,86 @@
|
||||
/* global process, fetch, AbortSignal, console, setTimeout */
|
||||
// Credentials stay in the process environment; never print raw API errors.
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN?.trim()
|
||||
const listen = process.argv.includes('--listen')
|
||||
const pending = process.argv.includes('--pending')
|
||||
const startedAt = Math.floor(Date.now() / 1000)
|
||||
const marker = '/goodbuddy_test'
|
||||
|
||||
async function call(method, body = {}, timeout = 15000) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout)
|
||||
})
|
||||
} catch (error) {
|
||||
const code = error?.cause?.code ?? error?.name
|
||||
const safeCode = /^[A-Z_a-z0-9]+$/.test(code ?? '') ? code : 'NETWORK_ERROR'
|
||||
// Raw network errors can embed the credential-bearing request URL.
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error(`${method}: ${safeCode}`)
|
||||
}
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
throw new Error(`${method}: invalid JSON (HTTP ${response.status})`)
|
||||
}
|
||||
if (!response.ok || !data.ok) {
|
||||
const code = Number.isInteger(data.error_code) ? data.error_code : response.status
|
||||
const wait = Number.isInteger(data.parameters?.retry_after)
|
||||
? `; retry_after=${data.parameters.retry_after}` : ''
|
||||
throw new Error(`${method}: API error ${code}${wait}`)
|
||||
}
|
||||
return data.result
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
||||
throw new Error('Set TELEGRAM_BOT_TOKEN to a valid BotFather token.')
|
||||
}
|
||||
const bot = await call('getMe')
|
||||
console.log(JSON.stringify({ check: 'getMe', ok: true, username: bot.username }))
|
||||
const webhook = await call('getWebhookInfo')
|
||||
console.log(JSON.stringify({
|
||||
check: 'getWebhookInfo', ok: true,
|
||||
webhookConfigured: Boolean(webhook.url), pendingUpdates: webhook.pending_update_count
|
||||
}))
|
||||
if (!listen && !pending) return
|
||||
if (webhook.url) throw new Error('Webhook is configured; polling skipped. No configuration changed.')
|
||||
console.log(pending
|
||||
? `Checking queued private ${marker} messages from the last hour.`
|
||||
: `Send ${marker} in a private chat with @${bot.username} within 45 seconds.`)
|
||||
const deadline = Date.now() + 45000
|
||||
while (Date.now() < deadline) {
|
||||
// Do not advance offset or change allowed_updates on a diagnostic run.
|
||||
const updates = await call('getUpdates', { timeout: pending ? 0 : 10, limit: 100 }, 15000)
|
||||
const message = updates.map((update) => update.message).reverse().find((item) =>
|
||||
item?.chat?.type === 'private' && !item.from?.is_bot &&
|
||||
item.date >= startedAt - (pending ? 3600 : 0) && item.text === marker
|
||||
)
|
||||
if (message) {
|
||||
await call('sendMessage', {
|
||||
chat_id: message.chat.id,
|
||||
text: 'GoodBuddy Telegram API test passed. This is a connectivity test, not an AI task.'
|
||||
})
|
||||
console.log(JSON.stringify({ check: 'privateMessageRoundTrip', ok: true }))
|
||||
return
|
||||
}
|
||||
if (updates.length >= 100) {
|
||||
throw new Error('Pending update batch is full; stopped without confirming existing updates.')
|
||||
}
|
||||
if (pending) break
|
||||
if (updates.length) await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
}
|
||||
console.log(JSON.stringify({ check: 'privateMessageRoundTrip', ok: false, reason: 'no_matching_message' }))
|
||||
process.exitCode = 2
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,5 +1,166 @@
|
||||
# 进度与证据
|
||||
|
||||
## 2026-10-05 K07/K08 局部修复
|
||||
|
||||
最终集成补注:排除并行删除功能后,待提交内容的隔离全量测试为 6,212 项通过、0 失败、87 跳过,类型检查及 lint 通过。下文分组阶段的失败轮次不是最终结果;完整证据见[KISS 验收](../../review/kiss-local-fixes-2026-10-05.md#最终检查)。
|
||||
|
||||
K07 已修复共享 Runtime 在模型路由清理阶段阻塞控制队列、取消后迟到安装留下路由的
|
||||
问题。broker 关闭与终态提交顺序保留,原生路由撤销在队列外完成;迟到安装按原
|
||||
transport/Session/operation 再次清理。transport 退出中止未完成路由 HTTP 请求。
|
||||
规则见[Session 所有权](../assistant-workbar/runtime-process-reuse-technical-design.md#61-连接与所有权)。
|
||||
|
||||
K08 在监督正文超容量时立即取消,清空正文并只消费用量事件,清理等待上限 1 秒。
|
||||
原容量错误优先于后续流错误与超时,不重试、不重复记账;超时以后返回的事件不写库。
|
||||
该路径由桌面直连模型执行,远程项目的监督也使用同一实现;具体用量规则见
|
||||
[监督模型调度](../conversation-supervision/technical-design.md#模型阶段超时与调度)。
|
||||
|
||||
### 回归与检查
|
||||
|
||||
首轮七项持久回归在修复前全部失败:取消/截止时间后的 peer 查询仍等待路由释放,
|
||||
迟到安装残留,OpenAI/Anthropic 已收到的 12 input/3 output 用量丢失,以及超限后的
|
||||
记账消费与清理边界不符。修复后增加缓冲完成响应、transport dispose/exit 回归,
|
||||
共十一项新增用例通过。检查取消终态只有一条、peer 仍 running、无重复 Prompt、
|
||||
用量只有一个 call,以及迟到事件不会在池槽释放后写入。
|
||||
|
||||
```text
|
||||
npx --no-install vitest run src/main/assistant/supervision-model-retry.test.ts src/main/assistant/supervision-production.test.ts src/main/agent/model-runtime.test.ts src/agent-daemon/runtime-acp-backend.test.ts src/agent-daemon/agent-owned-acp-prompt.test.ts src/agent-daemon/protocol-server.test.ts
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
上述六文件 243 项通过、1 项跳过;12:22 的 Main/Agent/Renderer 类型检查及全仓 lint 通过。
|
||||
并发工作树的完整 `npm test` 为 6,214 项通过、5 项失败、87 项跳过,耗时 870.20 秒;
|
||||
失败位于监督布局、IPC 的 provider 重试、监督恢复和快照读取测试,K07/K08 文件均通过。
|
||||
原始输出为批准临时目录中的 `k07-full-tests-1791174423486.log` 及同名 `.json`。
|
||||
这轮结果不能当作全仓验收通过;未修改其他任务负责的数据库、IPC 或界面文件。
|
||||
12:45 单独复跑 IPC provider 场景通过(1 项通过、190 项按筛选跳过);期间其他任务仍在
|
||||
修改测试,后一次类型检查报 `supervision-ui-reads.test.ts` 的 `publishing` phase 类型
|
||||
错误。最终整体检查需由合并工作树的负责方复跑,不能用本模块的通过结果替代。
|
||||
|
||||
### Linux x64 当前源码
|
||||
|
||||
复用前阶段 `a01-runtime-host` 的凭据与 SSH 固定身份入口,仅以 esbuild 生成当前
|
||||
Desktop driver、Agent daemon 和 CLI/helper 测试 bundle。使用 Host 已安装的原生
|
||||
OpenCode/Continue,在 `/root/tmp/gb-lifecycle-*` 专用目录运行,未安装生产包或更换
|
||||
Host current。阻塞点只由 harness 注入;路由安装、撤销及 409 缺失校验通过真实 helper。
|
||||
|
||||
| 场景 | peer 查询 | 取消完成 | 外部文本 HTTP 请求 | 结果 |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| 首轮 OpenCode | 51 ms | 206 ms | 2 | 取消及迟到路由清理通过;后续工具恢复触及旧夹具的两次调用上限 |
|
||||
| 最终 OpenCode | 13 ms | 176 ms | 3 | peer 完成、工具执行、detach/恢复、测试 Agent 提升、终态重放通过 |
|
||||
| 最终 Continue | 46 ms | 194 ms | 3 | 同上 |
|
||||
|
||||
准确合计 **8 次外部文本提供商 HTTP 请求、0 次图片提供商调用**,八个响应均 HTTP 200。
|
||||
最终每个 Runtime 包含一次短文本 peer 调用和两次工具/恢复调用,预算在 harness 中
|
||||
显式设为三次;被取消的启动未调用提供商。三个运行结束时所属进程均为 0,额外的
|
||||
只读 SSH/SFTP 检查确认本轮时间范围内的专用目录残留为 0,该检查新增模型调用 0 次。
|
||||
延迟是单次场景观察,不作为 p95 或桌面性能预算结论。
|
||||
|
||||
最终 Host 事件通过现有批处理器和隔离 SQLite 校验:OpenCode 41 条事件、21 个
|
||||
checkpoint;Continue 16 条事件、8 个 checkpoint;重放去重通过。没有读写用户生产库。
|
||||
|
||||
原始脚本位于 `C:/Users/jiang/AppData/Local/Temp/opencode`:
|
||||
`k07-runtime-host-build.cjs`、`k07-runtime-host-run.cjs`、`k07-host-cleanup-build.cjs`。
|
||||
日志位于其 `k07-runtime-host/` 子目录:`opencode-1791173985798.log`、
|
||||
`opencode-1791174187014.log`、`continue-1791174187010.log`、`inspect-1791175306647.log`。
|
||||
验证不覆盖签名安装包、macOS、Linux arm64、完整 UI 操作或生产规模性能;远程修复需
|
||||
部署包含本次源码的 Agent,当前未提交或发布。
|
||||
|
||||
## 2026-10-05 Runtime 生命周期审查修复
|
||||
|
||||
A01(远程启动阻塞控制队列)和 A02(本机 OpenCode 提交失败后等待静默 SSE)已修复。
|
||||
远程启动等待释放 backend 控制队列及协议接收分派,取消、watchdog、关闭和其他
|
||||
binding 可以继续处理;Session owner 合并相同启动、拒绝冲突,并关闭取消后迟到的
|
||||
原生 Session。本机提交错误立即唤醒所属 SSE 消费,保留原错误和既有 Session 取消。
|
||||
两条实现均未增加整个 Prompt 的默认总时限;共享进程、终态提交和写帧顺序规则见
|
||||
[Runtime 进程复用设计](../assistant-workbar/runtime-process-reuse-technical-design.md)。
|
||||
|
||||
### 确定性复现
|
||||
|
||||
持久回归中,本机拒绝、错误响应、10 ms 提交超时三种情况在旧代码经过 100 ms
|
||||
假时钟后仍未结算;远程共享进程中的第二个 Session 启动被挂起时,旧代码经过
|
||||
200 ms 后,取消/截止时间和同进程另一 binding 的查询均未结算。上述五项先运行旧
|
||||
实现,全部按这些断言失败。真实 Host 首轮还定位到协议 dispatcher 串行等待启动
|
||||
响应,新增帧级回归在修复前只分派 `start`,没有分派后续 `cancel` 和 `peer`。
|
||||
|
||||
修复后的九项新增回归通过,另覆盖启动 watchdog、默认关闭时限、相同启动重放及
|
||||
冲突。共享 peer 保持 running;迟到 Session 收到自身的 `session/close`,取消的
|
||||
启动不发送 Prompt。原始审查探针曾在 131 秒假时钟后观察到启动、取消、peer 查询
|
||||
仍等待,默认 10 秒 dispose 报超时且 stop 次数为 0,现有关闭与 watchdog 回归不再
|
||||
等待原生初始化响应。各假时钟值是确定性边界,不是系统性能测量。
|
||||
|
||||
### 当前源码实机验证
|
||||
|
||||
复用已有 lifecycle harness,用 esbuild 重建当前 Desktop driver、Agent daemon、
|
||||
CLI/helper;凭据从 GoodBuddy 已有加密配置的临时副本加载,SSH 校验已固定 Host
|
||||
identity。共享 Linux x64 经 `192.168.0.23` 连接,只使用 `/root/tmp/gb-lifecycle-*`
|
||||
专用目录和所属进程,未替换原 Host 安装或修改无关数据。
|
||||
|
||||
| 场景 | 实际结果 | 真实文本提供商调用 |
|
||||
| --- | --- | ---: |
|
||||
| 首轮 Host 启动挂起 | backend 修复后,协议 dispatcher 仍阻塞;测试退出后所属进程为 0 | 0 |
|
||||
| OpenCode 启动取消及工具、detach、controller 恢复、测试 Agent 提升、重放 | 启动响应被挂起时控制查询 48 ms、取消 216 ms;工具与恢复完成;两个提供商响应均 HTTP 200 | 2 |
|
||||
| Continue 同场景 | 控制查询 60 ms、取消 258 ms;工具与恢复完成;两个提供商响应均 HTTP 200 | 2 |
|
||||
| Windows 当前源码 OpenCode 普通文本 | 真实原生二进制经有界 loopback 转发访问已配置模型,返回 `LOCAL_RUNTIME_OK`,0 工具调用;总耗时 4,727 ms | 1 |
|
||||
|
||||
本轮合计 **5 次真实文本提供商调用、0 次图片生成调用**。两次本机脚本准备失败发生在
|
||||
SDK 动态加载阶段,均为 0 次模型调用,随后修正测试 bundle 并通过。所有成功场景均
|
||||
使用最终相关生产源码。Host 启动取消阶段各为 0 次模型调用,最终所属残留进程均为 0,
|
||||
专用目录由 harness 清理;本机 Runtime 和凭据临时副本也已清理。
|
||||
|
||||
真实数据检查使用本轮原生工具和模型产生的事件:OpenCode 45 条写入、23 个 checkpoint,
|
||||
Continue 16 条写入、8 个 checkpoint,经生产 `AssistantDatabase` 和
|
||||
`RemoteEventBatcher` 写入隔离 SQLite,再重放确认全部去重。未迁移、改写用户生产
|
||||
历史库,也未以构造事件代替本轮 Host transcript。
|
||||
|
||||
原始脚本和日志位于 `C:/Users/jiang/AppData/Local/Temp/opencode`:
|
||||
`a01-runtime-host-build.cjs`、`a01-runtime-host-run.cjs`、`a01-runtime-host/`,以及
|
||||
`a02-local-model.ts`、`a02-local-model-run.cjs` 和 `a02-local-model-1791142126078.log`。
|
||||
Host 成功日志为 `opencode-1791141713375.log`、`continue-1791141785721.log`。
|
||||
延迟为各场景一次实测,不宣称吞吐、p95 或完整桌面性能提升。
|
||||
|
||||
### 检查命令与边界
|
||||
|
||||
```text
|
||||
npx vitest run src/agent-daemon --testTimeout=30000 --hookTimeout=60000
|
||||
npx vitest run src/main/remote-agent src/main/agent/acp-remote-runtime.test.ts src/main/agent/runtime-controller.test.ts src/main/agent/local-runtime-registry.test.ts --testTimeout=30000
|
||||
npx vitest run src/main/agent/opencode-runtime.test.ts
|
||||
npx vitest run src/main/agent/opencode-runtime-lifecycle.test.ts --testTimeout=30000 --hookTimeout=60000
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
四组测试分别为 323 通过/33 跳过、393 通过、156 通过、4 通过,共 876 项通过;
|
||||
typecheck、全仓 lint 和修改范围 `git diff --check` 通过。初次合并运行中,既有
|
||||
OpenCode `cleanup-timeout` 用例失败一次;该用例独立复跑及随后完整 OpenCode 文件
|
||||
复跑通过,未改动该用例或相关产品清理逻辑。较早 Main 类型检查的并发监督者 fixture
|
||||
错误在最终全仓检查时已消失。
|
||||
|
||||
验证覆盖当前源码、真实 Windows OpenCode 二进制和共享 Linux x64 Host,不含签名包
|
||||
安装验收、完整 UI 点击、macOS/Linux arm64 或用户生产大库性能测试。首轮定向验证
|
||||
未运行全仓 `npm test`,后续完整检查见下节;远程更新仍须部署包含本次源码的 Agent,
|
||||
未提交或发布。
|
||||
|
||||
### SDK cleanup-timeout 复查
|
||||
|
||||
后续全仓验证为 6,130 通过、1 失败、87 跳过,唯一失败是 OpenCode SDK
|
||||
`cleanup-timeout` 用例。复查通过独立 Node 子进程的十轮显式 GC 和受控取消复现:
|
||||
旧 fetch 替身只保存 `request.signal`,返回永不结算的 Promise,没有在途 Request
|
||||
所有者或 abort 处理。Request 及 Undici 内部 AbortController 被回收后,外层超时
|
||||
signal 已中止,保存的 Request signal 仍未中止。真实 loopback fetch 在同样 GC
|
||||
条件下保留 Request,取消后 SDK 返回错误,未观察到产品清理提前完成。
|
||||
|
||||
修正仅涉及测试:挂起的 fetch 替身在 abort 时以 `request.signal.reason` 拒绝,
|
||||
闭包保持 Request 存活至取消。原有 `signals.every(signal => signal.aborted)`、调用
|
||||
顺序、错误脱敏及超时均保留。新增 GC 回归在旧替身上稳定失败,修正后通过;九项
|
||||
SDK 定向用例、Main 类型检查及该文件 ESLint 通过。本次跟进新增外部模型调用 0 次,
|
||||
生产代码与远程 Agent 路径均未改动,无需重新执行 Host 模型验证。
|
||||
|
||||
修正后的完整 `npm test` 于 04:00:22 开始,852.63 秒完成,退出码 0:518 文件通过、
|
||||
15 文件跳过;6,136 项通过、0 失败、87 跳过。计数包含并发工作树中的其他修复。
|
||||
原始记录为 `C:/Users/jiang/AppData/Local/Temp/opencode/a02-cleanup-final-20261005.log`
|
||||
及同名 `.json`;GC/真实 loopback 对照脚本为该目录的 `a02-cleanup-gc-probe.mjs`。
|
||||
|
||||
## 2026-10-04 调研与设计
|
||||
|
||||
调研阶段,用户确认彻底移除 Ask / Execute,包括界面、原生工具与 MCP 限制,并允许升级清理历史模式字段。该阶段先交付文档,随后用户确认删除安全设置并授权实施;实现结果见下文。
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 首期范围 | 微信 ClawBot、企业微信、钉钉的通道项目;微信文字、图片和文件请求 |
|
||||
|
||||
2026-10-05 枚举更新:当前通道项目已增加 Telegram,下文同步更新通用项目、界面和契约枚举;首期范围、背景与第 17 节保留原实施范围。Telegram 的产品行为见[Telegram PRD](../telegram-channel/prd.md),验证状态见[进度记录](../telegram-channel/progress.md)。
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入系统管理的通道项目。该信息架构解决了早期通道仅存在于设置页和后台服务中,远程消息进入任务系统后无法持续、明确识别以下信息的问题:
|
||||
@@ -30,8 +32,8 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
|
||||
## 2. 已确认的产品决策
|
||||
|
||||
1. 微信 ClawBot、企业微信、钉钉分别对应一个系统管理的通道项目。
|
||||
2. 三个通道项目在通道设置服务初始化时幂等创建,不等待用户完成连接配置。
|
||||
1. 微信 ClawBot、企业微信、钉钉、Telegram 分别对应一个系统管理的通道项目。
|
||||
2. 四个通道项目在通道设置服务初始化时幂等创建,不等待用户完成连接配置。
|
||||
3. Renderer 不直接创建通道项目。Main 进程保证项目存在,设置卡片首次展示时即可引用对应项目。
|
||||
4. 通道项目默认根目录为当前操作系统用户目录。
|
||||
5. 同一通道中的不同私聊用户或群聊分别建立独立远程会话。
|
||||
@@ -48,7 +50,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
|
||||
### 3.1 用户目标
|
||||
|
||||
- 在项目切换器中一眼识别微信、企业微信和钉钉来源。
|
||||
- 在项目切换器中一眼识别微信、企业微信、钉钉和 Telegram 来源。
|
||||
- 在通道项目中区分不同发送者和群聊。
|
||||
- 在设置卡片中完成连接、启停、处理后端和工作目录配置。
|
||||
- 通过微信问答或发起工具任务。
|
||||
@@ -89,7 +91,8 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
远程通道
|
||||
├─ 微信 ClawBot
|
||||
├─ 企业微信
|
||||
└─ 钉钉
|
||||
├─ 钉钉
|
||||
└─ Telegram
|
||||
```
|
||||
|
||||
选择器收起时显示当前项目名称;展开后每个选项至少显示项目名称和项目类型。本地项目补充目录摘要,远程通道项目补充平台来源和默认目录;接入可复用的通道状态数据后继续补充文字连接状态。未来接入其他远程项目时可以补充远程主机、工作区或服务来源,而不改变菜单的基本结构。
|
||||
@@ -137,7 +140,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
|
||||
应用初始化通道设置服务时,Main 进程执行 `ensureChannelProjects()`:
|
||||
|
||||
1. 按稳定通道标识查找 `weixin`、`wecom`、`dingtalk` 对应项目。
|
||||
1. 按稳定通道标识查找 `weixin`、`wecom`、`dingtalk`、`telegram` 对应项目。
|
||||
2. 缺失时创建系统管理项目。
|
||||
3. 已存在时复用,不重复创建。
|
||||
4. 如果存在同名普通项目,不得按名称占用或修改该项目。
|
||||
@@ -150,6 +153,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
| `weixin` | 微信 ClawBot | 用户目录 | 默认直连文本模型 |
|
||||
| `wecom` | 企业微信 | 用户目录 | 默认直连文本模型 |
|
||||
| `dingtalk` | 钉钉 | 用户目录 | 默认直连文本模型 |
|
||||
| `telegram` | Telegram | 用户目录 | 默认直连文本模型 |
|
||||
|
||||
### 6.2 系统管理约束
|
||||
|
||||
@@ -176,11 +180,11 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
设置页标签由“企业通信”改为:
|
||||
|
||||
- 标题:消息通道
|
||||
- 说明:微信 ClawBot、企业微信与钉钉
|
||||
- 说明:微信 ClawBot、企业微信、钉钉与 Telegram
|
||||
|
||||
### 7.2 卡片通用结构
|
||||
|
||||
三个通道使用与模型设置一致的分段外观页签,并保留
|
||||
四个通道使用与模型设置一致的分段外观页签,并保留
|
||||
`tablist`、`tab`、`tabpanel`、游标焦点和方向键语义。每次只显示一个
|
||||
通道面板,窄窗口下页签单行横向滚动。
|
||||
|
||||
@@ -294,6 +298,8 @@ Agent Runtime。OpenCode/Continue 的通道项目只持久化 `provider`,旧
|
||||
|
||||
现有发送者白名单和群聊提及设置继续有效。
|
||||
|
||||
钉钉回复以接口响应中的 `errcode=0` 确认投递,HTTP 200 本身不代表成功。业务错误或无效响应保留回复上下文和发件箱结果,由现有有限重试补发,不重新执行任务。响应读取上限为 64 KiB,包含没有 `Content-Length` 的分块响应;错误提示只保留数值错误码,不回显平台响应中的敏感字段。
|
||||
|
||||
## 8. 远程会话需求
|
||||
|
||||
### 8.1 会话创建与复用
|
||||
@@ -500,7 +506,7 @@ Main 到 Sidecar:
|
||||
|
||||
```ts
|
||||
type ProjectKind = 'user' | 'channel'
|
||||
type ProjectChannel = 'weixin' | 'wecom' | 'dingtalk'
|
||||
type ProjectChannel = 'weixin' | 'wecom' | 'dingtalk' | 'telegram'
|
||||
```
|
||||
|
||||
通道项目包含:
|
||||
@@ -610,7 +616,7 @@ Renderer 快照只返回是否已配置和脱敏标识。
|
||||
## 15. 可访问性与响应式
|
||||
|
||||
- 通道状态同时使用文字和图标。
|
||||
- 三个通道使用共享 `PageTabs` 的 `segmented` 外观,保留页签语义和方向键切换。
|
||||
- 四个通道使用共享 `PageTabs` 的 `segmented` 外观,保留页签语义和方向键切换。
|
||||
- 后端选择保留语义化控件和键盘操作,不留已删除模式控件的焦点目标。
|
||||
- 消息处理后端使用持久标签和分组选项,并说明当前选择的实际行为。
|
||||
- 二维码提供状态文字和备用刷新操作,但不把敏感二维码链接作为可复制文本。
|
||||
@@ -623,7 +629,7 @@ Renderer 快照只返回是否已配置和脱敏标识。
|
||||
|
||||
### 16.1 通道项目
|
||||
|
||||
- [ ] 新安装首次启动后存在微信 ClawBot、企业微信和钉钉三个通道项目。
|
||||
- [ ] 新安装首次启动后存在微信 ClawBot、企业微信、钉钉和 Telegram 四个通道项目。
|
||||
- [ ] 重启应用不会重复创建通道项目。
|
||||
- [ ] 同名普通项目不会被占用或修改。
|
||||
- [ ] 通道项目默认根目录为当前用户目录,不保存默认模式。
|
||||
@@ -636,8 +642,8 @@ Renderer 快照只返回是否已配置和脱敏标识。
|
||||
### 16.2 设置卡片
|
||||
|
||||
- [ ] 设置标签显示为“消息通道”。
|
||||
- [ ] 三个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。
|
||||
- [ ] 三个面板均显示项目、根目录、消息处理后端和连接状态。
|
||||
- [ ] 四个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。
|
||||
- [ ] 四个面板均显示项目、根目录、消息处理后端和连接状态。
|
||||
- [ ] 微信卡片可以完成扫码、过期刷新、验证码和连接状态展示。
|
||||
- [ ] “断开本机绑定”使用共享红色危险操作样式,并明确说明只清除本机凭据、不删除历史或承诺服务端解绑。
|
||||
- [ ] 不显示“自动”后端;首次创建和旧版 `auto` 配置均落到默认直连文本模型。
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
|
||||
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.9,截至 `9527402` 的已提交变化,更新于 2026-10-03。所述功能与修复均已开发完成并提交;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.11,以及其后截至 `e50ca6d1` 的已提交变化,更新于 2026-10-05。所述功能与修复均已开发完成并提交,验证及发布归属另行标注;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
|
||||
|
||||
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者把分散的事件归入持续发展的故事,从中整理带有来源和适用条件的经验,供人和助手在后续工作中查阅。笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
|
||||
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签、会话图片保存,以及按会话、项目和全局分别选择 Runtime 与模型的方式,并继续改善长会话的浏览与输入。远程使用建议配套 Desktop 0.15.9 和 Agent 0.15.3,请先升级桌面端,再更新 Host 环境。
|
||||
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳、笔记标签、会话图片保存、Telegram 私聊和工作区文件导入。项目与会话集中到同一个菜单,提问和执行任务也无需再切换工作模式。远程统一执行与文件导入建议配套 Desktop 0.15.11 和 Agent 0.15.4,请先升级桌面端,再更新 Host 环境;最新远程启动修复还需包含后续提交的 Agent。
|
||||
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.9` 与 Agent tag `agent-v0.15.3` 均指向 `9527402`。本轮检查 `b02ce31..9527402`,共 45 个提交;图片保存、笔记取消及默认开关调整已包含在桌面 0.15.8 中。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.11` 与 Agent tag `agent-v0.15.4` 均指向 `78afec77`。本轮检查 `9527402..e50ca6d1`,共 60 个提交;Telegram 私聊、历史耗时缺失提示、自定义回顾日期及最新运行修复位于这些 tag 之后,发布版本待确认。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
|
||||
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前助理数据库格式为 56,包含共享时间线、故事归属、经验及建议等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
|
||||
**升级前,请先关闭应用并备份完整用户数据。** 数据库、设置和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前核查提交的助理数据库格式为 61,0.15.11 标签对应格式为 60;升级后旧客户端不能直接打开新版数据库,回退需要升级前的备份。大库首次转换可能需要等待。高频本地保存调整了磁盘同步策略,断电时可能丢失最近尚未同步的写入。
|
||||
|
||||
## 新增功能
|
||||
|
||||
### 监督者:从工作记录中整理进展,查看每条结论的依据
|
||||
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
|
||||
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,再选择最近 1 天、7 天、30 天、自定义最近天数或起止日期。指定日期按本地时区包含结束日全天,结束日为今天时截至发起回顾的时刻。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
|
||||
|
||||
监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。
|
||||
|
||||
@@ -34,7 +34,9 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。
|
||||
|
||||
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径随消息活动量变化,不代表实际工时、效率或成果价值。
|
||||
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径按所选回顾范围内的消息密度变化,不代表实际工时、效率或成果价值;消息按小时汇总,无法还原小时内的精细分布。
|
||||
|
||||
实际事件区间和事件点使用主故事的颜色,空白区间与未归属事件保持中性色。历史回顾中尚未归入故事的事件也会保留,可以在“未归属”分组查看;故事分组采用当前关系,不把它当作当时分类的完整还原。
|
||||
|
||||
视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。
|
||||
|
||||
@@ -58,7 +60,9 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
工具说明也会引导助手在继续旧工作、追问决定原因或遇到可能已有约定的任务时主动查阅,优先找故事与经验。是否调用仍由当前模型判断;普通闲聊和独立的通用问题不要求查询,也不会在每次对话中自动塞入整张图谱。
|
||||
|
||||
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;Ask 与 Execute 均可只读查询。本地 Continue、OpenCode 原生终端也可读取。
|
||||
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;本地 Continue、OpenCode 原生终端也可读取。
|
||||
|
||||
输入区的选项中可以为当前会话单独开关故事图谱访问。默认允许查询,但仍受监督者总开关和工具配置约束;开启不会自动运行回顾或注入上下文,关闭也不会删除图谱,或将这个会话排除在后续回顾之外。
|
||||
|
||||
查询默认使用当前项目,也可明确指定全局或项目集合。事件可从共享时间线中按项目查阅,回顾结果仍保留各自的范围。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,尚不支持任意时刻的完整历史状态重建。
|
||||
|
||||
@@ -80,7 +84,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
带入的结论按所选来源关联的事件、实体与关系整理,方便围绕当前事项继续追问。监督界面已移除知识实体写回控件。
|
||||
|
||||
后续讨论的最终内容最多 12,000 字符。固定任务没有关联会话时,不能直接发送继续讨论;后续对话沿用目标会话的模型和工作模式。
|
||||
后续讨论的最终内容最多 12,000 字符。固定任务没有关联会话时,不能直接发送继续讨论;后续对话沿用目标会话的 Runtime、模型与能力配置。
|
||||
|
||||
### 聊天正文存入魔法笔记,并保留来源
|
||||
|
||||
@@ -102,7 +106,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
给笔记加上“项目资料”“待整理”等标签后,可以按标签查找,也可以同时选择多个标签,筛出同时带有这些标签的笔记。每篇最多添加 10 个标签;标签支持改名、同名合并和删除,删除标签会保留笔记正文。
|
||||
|
||||
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口改为列表、详情切换;记录索引默认收起,“新建笔记”固定在页头。
|
||||
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口可从正文工具栏打开笔记列表抽屉,选完笔记后继续阅读;记录索引默认收起,“新建笔记”固定在页头。
|
||||
|
||||
正文使用当前栏的可用宽度,宽窗口下阅读画布和长内容有更多空间。新建文字或画布记录都可以取消,有草稿时先确认是否放弃;取消编辑、切换记录类型或返回聊天后,焦点回到对应的按钮或输入位置。
|
||||
|
||||
@@ -114,7 +118,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
接入后,可以请助手查找笔记、读取内容,或将讨论结果写回仓库;也支持追加、局部修改、目录浏览,以及标签和属性操作。笔记仍保存在原仓库,不会自动复制成 GoodBuddy 知识库。
|
||||
|
||||
目前支持直连模型及 GoodBuddy 管理的本机 OpenCode、Continue 聊天,默认关闭;DeepSeek Harness 和远程执行暂不支持。Ask 只读,Execute 可写入;多仓库调用需要明确目标仓库。接入本机文件不等于离线推理,读出的内容会随当前会话交给所选模型处理。
|
||||
目前支持直连模型及 GoodBuddy 管理的本机 OpenCode、Continue 聊天,默认关闭;DeepSeek Harness 和远程执行暂不支持。启用并分配后,可按请求读写已配置范围内的笔记;多仓库调用需要明确目标仓库。接入本机文件不等于离线推理,读出的内容会随当前会话交给所选模型处理。
|
||||
|
||||
> **【待补图 07|建议|Obsidian 接入与读写】** 已有候选 `obsidian.png` 展示仓库范围和 Runtime 分配,尚需确认构建来源;正文主图优先补公开笔记的搜索、写入与回读结果。计划文件:`assets/07-obsidian-notes.png`。
|
||||
>
|
||||
@@ -126,7 +130,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
原生客户端使用独立会话,不会自动接续或同步 GoodBuddy 中的聊天历史。DS 网页关闭后,本地服务继续运行,可以回到 GoodBuddy 重新打开或停止;退出应用会清理所属服务。
|
||||
|
||||
当前入口支持本地项目,远程原生客户端尚未接通。Continue 与 DS Web 需要可用的标准 Node 环境,首次准备环境可能需要下载。Continue、OpenCode 原生终端在 Ask 下可使用已分配的内置只读工具,包括故事图谱查询;自定义 MCP 仍仅在 Execute 下接入。
|
||||
当前入口支持本地项目,远程原生客户端尚未接通。Continue 与 DS Web 需要可用的标准 Node 环境,首次准备环境可能需要下载。Continue、OpenCode 原生终端可使用已启用并分配的内置工具及自定义 MCP;故事图谱工具保持只读。
|
||||
|
||||
> **【待补图 08|建议|原生终端与 DS Web】** 已有 `continue.png`、`opencode.png`、`deepseekweb.png` 候选;发布前确认构建来源,并处理本机路径、浏览器书签及与演示无关的会话。Continue 图含模型能力警告,建议换用合适模型重拍;不能据启动画面宣称完整对话验收通过。计划文件:`assets/08-native-clients.png`。
|
||||
>
|
||||
@@ -158,9 +162,25 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
### 让助手把会话图片保存到指定文件
|
||||
|
||||
在 Execute 模式下,可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
|
||||
可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
|
||||
|
||||
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址;Ask 模式不提供写入操作。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
|
||||
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址。成果页已移除,历史图片和成果记录仍保留;保存图片无需先进入成果页,既有成果也不会自动复制到项目目录。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
|
||||
|
||||
### 通过 Telegram 私聊使用通道项目
|
||||
|
||||
配置自己创建的 Telegram Bot、数字用户 ID 白名单和通道项目后,可以在 Telegram 私聊中发送文字问题或任务。GoodBuddy 沿用该项目的 Runtime、模型和工作目录处理请求,返回最终文字回复,并在桌面保留会话记录。
|
||||
|
||||
首次配置可以先留空发送者 ID,启用并保存通道。连接后私聊 Bot,发送 `/start` 和 `/whoami` 获取自己的数字用户 ID,填回白名单并再次保存后即可发送 AI 请求;仅测试连接不会开始接收消息。
|
||||
|
||||
当前支持单个 Bot 的普通私聊,桌面应用需要保持运行并能连接 Telegram。连接使用系统代理,支持断线恢复和停用取消;排除连接故障后,也可原样保存配置重新连接。目前不处理群聊、媒体输入或附件发送,图片等附件需回到桌面查看。
|
||||
|
||||
> **【待核对 C02|Telegram 实际联调】** 功能已提交,真实 Bot 与模型的完整收发验收仍待完成。对外发布前用专用演示 Bot 核对文字请求、回复、断线恢复与停用行为,不将本轮静态核查写成实际联调通过。
|
||||
|
||||
### 将本机文件导入本地或远程工作区
|
||||
|
||||
工作区文件面板的“新建文件”旁新增导入入口。选定目标目录后,可以批量选择本机文件,复制到本地项目或上传到远程 Host,随后在文件列表中查看。远程导入需要配套 Agent 0.15.4 或包含该功能的更新版本。
|
||||
|
||||
导入遇到同名文件时保留原文件,单个文件失败后继续处理其余文件。列表可以按名称、修改时间或可用的创建时间排序;排序针对当前目录已读取的条目,每目录最多 500 条。
|
||||
|
||||
### 桌面通知可以单独关闭
|
||||
|
||||
@@ -174,6 +194,18 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
## 功能改进
|
||||
|
||||
### 请求直接使用当前 Runtime 与能力配置
|
||||
|
||||
聊天、任务和通道请求不再需要切换 Ask/Execute,也移除了原有的通用工具审批策略。选好 Runtime、模型与已启用能力后,即可提出请求;只需解释时可以直接说明,文件和工具操作使用执行账户的实际权限。
|
||||
|
||||
能力开关、工具分配、资源范围和取消操作继续生效,各 Runtime 的支持范围也仍有区别。监督回顾、摘要等专用文本分析任务保持无工具。已有会话内容保留,旧工作模式设置不再决定后续请求的行为。
|
||||
|
||||
### 项目与会话集中到一个菜单
|
||||
|
||||
左上角的双行按钮统一显示项目与当前会话,点击后浏览本地、远程和通道项目。可以分类搜索,悬停预览项目会话,也可以直接跨项目进入正在处理的工作;切回项目时恢复上次打开的会话。
|
||||
|
||||
菜单同时提供最近 10 个会话,以及待处理和运行中的工作;按已完成状态筛选时可以继续查找历史会话。项目设置、归档恢复和离开未保存草稿前的提示仍可使用。新建本地项目必须选择目录,远程目录选择器支持进入指向目录的符号链接。
|
||||
|
||||
### 会话搜索按需展开,快捷键提示集中在输入框
|
||||
|
||||
侧栏的搜索入口与“新建对话”放在同一行,点击后展开并自动聚焦,可查找会话标题或消息正文。切换会话保留关键词;点击关闭按钮,或在搜索控件行内按 Escape,可清空并收起搜索。展开和收起时,会话列表位置保持不变。
|
||||
@@ -194,6 +226,8 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
故事线图谱侧栏可以按故事、经验、事件、实体和关系查看,列表与详情各自滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别。平铺与时间螺旋都使用中间画布的可用空间,视角等控件收进紧凑操作区,减少窗口较矮时内容被挤出屏幕的问题。
|
||||
|
||||
工作回顾与图谱共用选中的历史结果和范围说明,从总结切到图谱时继续查看同一份回顾。新回顾的输入区域可以收起,历史选择器和视角工具排列更紧凑;详情布局也适配较窄窗口。
|
||||
|
||||
### 手动回顾与智能心跳共用增量进度
|
||||
|
||||
原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。
|
||||
@@ -202,6 +236,8 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。
|
||||
|
||||
监督者设置中可以单独选择一条文本模型连接,用于回顾、故事和经验整理,以及建议生成;未指定时跟随默认文本模型。选定连接失效或缺少 API Key 时明确提示,不会自动换用其他模型。
|
||||
|
||||
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录,长时间范围可能分成多批;心跳不再额外生成一份独立报告。并发数和超时设置不等于总费用上限。
|
||||
|
||||
### 长回顾可以看进度、保留批次并继续处理
|
||||
@@ -232,11 +268,13 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。“系统任务”页签单列监督回顾、笔记、知识库等后台用量,并补齐回顾、图片重新生成、知识图谱提取和远程嵌入等调用的用量记录;过去未记录的用量无法补算。页面打开时加载,可见期间每 15 秒刷新,也可点击页头手动刷新。这里汇总的是应用记录,并非服务商计费账单。
|
||||
|
||||
会话和任务耗时按实际运行状态累计,包含模型与工具执行,等待用户回答时暂停,运行期间持续更新。并行请求的时间会相加,因此它不等于项目经历的日历时长。旧历史不补算,缺少计时数据且累计为零时显示“不可用”,与真实的零时长区分。
|
||||
|
||||
### 长对话输入、切换与流式回复减少重复处理
|
||||
|
||||
任务统计转到后台查询,隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
|
||||
任务耗时按运行状态累计,避免反复扫描历史消息和事件;隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
|
||||
|
||||
流式活动更新时,只保存变化的记录,并减少连续保存过程中反复读取整份历史的次数。改动针对工具活动较多、运行记录较长的对话,保留原有内容与顺序;实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
|
||||
运行记录按需分页加载,筛选时读取对应范围,保存时只传递变化的记录;页面计数与汇总仍覆盖已保存历史。离开页面后释放多余已加载页,重新查看时可以继续加载,保留原有内容与顺序。实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
|
||||
|
||||
聊天输入和流式回复减少了无关活动面板的刷新;置顶或取消置顶会话时,也不再附带扫描附件引用。笔记启动检查复用已读取的正文信息,减少重复读取,原有内容恢复与清理流程保留。
|
||||
|
||||
@@ -246,6 +284,8 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。
|
||||
|
||||
文档解析、分块与摘要哈希计算也转到后台线程;笔记网格、工作区文件树和知识库文档表主要绘制可见区域,终端连续输出合并刷新。启动时按需加载部分组件,并在后台准备工具环境,需要这些工具时再等待准备完成。
|
||||
|
||||
### 设置页减少来回查找
|
||||
|
||||
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
|
||||
@@ -254,7 +294,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
|
||||
|
||||
外观设置新增透明磨砂效果,当前默认开启;标题栏和相关卡片使用半透明背景,可以看到其后的应用内容。可以关闭恢复实色显示,已有的外观选择继续保留。
|
||||
|
||||
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。在 Execute 模式中,请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;Ask 仍保持只读。
|
||||
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;仍需先生成有效计划,再应用对应变更。
|
||||
|
||||
HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。
|
||||
|
||||
@@ -286,17 +326,29 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等
|
||||
|
||||
最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。
|
||||
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.3,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮基础配套为 Agent 0.15.4,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
|
||||
|
||||
最新修复使远程 Runtime 在等待启动时仍可取消,并避免阻塞其他会话;本地 OpenCode 提交失败后也能及时结束等待并显示原因。远程部分需要包含 `bbf24c28` 改动的 Agent,不属于已核查的 Agent 0.15.4 标签。
|
||||
|
||||
### 通道交付与统计结果
|
||||
|
||||
- 修复生成图片成功后,通道因结果格式校验而误报失败的问题。Telegram 仍只发送文字提示,附件需到桌面查看。
|
||||
- 钉钉返回业务错误时不再被当作送达成功,可以进入已有重试流程。
|
||||
- 修正 OpenCode 缓存命中率偏高、完全命中时显示为空的问题;已有用量重新查询后使用完整输入量计算,不表示实际缓存命中率提高。
|
||||
- 活动汇总覆盖已加载的会话集合,不再只统计前 5,000 个会话。
|
||||
- PPTX 解析警告显示实际使用的 OCR 服务及失败的幻灯片、图片位置,便于排查;这项修复不改变外部 OCR 服务本身的识别结果。
|
||||
|
||||
### 启动、搜索与回顾结果
|
||||
|
||||
- 修复正常聊天或保存笔记后,重启反复显示历史迁移的问题。普通数据库结构升级也不再因正常空闲页额外整理整库;确需转换的旧数据仍会处理,启动页区分实际操作。
|
||||
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出。Ask 保持工作区只读,Execute 使用当前账号权限;这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
|
||||
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出;工具使用当前执行账户权限。这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
|
||||
- 修复早期监督回顾只取部分会话、消息和正文的问题,改为对入选来源分页、分批处理。事实与来源独立保存,只有必要阶段完成并成功保存结果后才标记完成。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。网络错误、超时、取消和超出响应容量不走这套重试。
|
||||
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。
|
||||
- 监督模型遇到可识别的临时网络中断、限流或部分服务端错误时,清理失败请求后自动再试一次,等待期间可以取消。认证、配置、费用额度、超时、取消及容量错误不会触发这次重试;失败尝试已报告的用量仍会记录,重试可能增加模型费用。
|
||||
- 故事或经验整理失败时,已保存的回顾结果继续可用,可以单独重试整理。故事归类减少把各功能的提交与测试统归到“发布”的情况;经验应用记录也增加时间顺序与共同实体核对,归纳内容仍需结合来源判断。
|
||||
- 时间螺旋退出时释放图形资源;3D 初始化失败或图形上下文丢失时,在视图内提示并提供重试,也可切回平铺,避免整个监督页面无法使用。
|
||||
- 修复历史回顾入口跳到其他结果、切换后旧响应覆盖当前选择的问题。侧栏、活动记录和图谱按对应结果定位,人工确认与修订的内容保留。
|
||||
- 修复关闭跨项目关联后候选范围未正确限定、暂停发生在最终查询期间仍继续发布的问题。故事整理期间已移除的故事不会再收到新关联,刷新后的控件也不会因旧请求失效而一直禁用。
|
||||
|
||||
### 日常界面操作
|
||||
|
||||
@@ -306,6 +358,7 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
|
||||
- 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。
|
||||
- 笔记保存后退出编辑;从快速笔记跳到指定记录时,等待正文显示后再定位,减少落在错误位置的情况。
|
||||
- 切回已查看的会话时,立即显示缓存的累计回复时长,再后台刷新;缓存限本次应用运行期间。
|
||||
- 长对话滚出屏幕后再返回,推理、工具输入输出、引用和子代理详情保留展开选择;该状态限历史面板存续期间,不跨应用重启保存。跨屏选择、复制与滚动定位也作了修复,跳到底部恢复平滑滚动。
|
||||
- Mermaid 大图放大后,顶部和左侧等边缘仍可通过滚动、拖动到达,窄窗口中的控件可以换行。
|
||||
- Git 分支选择器悬浮在工作区上方,不再把文件列表和提交历史向下挤动;项目选择菜单可以浮出侧栏,长名称与路径有更多显示空间。
|
||||
- 文件差异页将查看文件、刷新和复制路径集中到路径旁,窄面板中可以换行;只有未暂存改动时,省去重复的分区标题。浏览器“前往”按钮改为紧凑图标,保留悬停提示。
|
||||
@@ -324,6 +377,7 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
|
||||
- MCP 编辑器和图片预览可以在父弹窗上正常操作;项目弹窗的 Escape 只处理当前弹窗,帮助层覆盖浏览器时不再被原生页面遮挡。
|
||||
- HTML 全屏预览恢复键盘进入与 Escape 返回,继续保持原有沙箱限制。
|
||||
- 窄工作栏的添加按钮不再被滚动箭头遮挡,模型连接协议文字保持可读;DS 入口移除常驻说明,重新打开和停止服务操作继续保留。
|
||||
- 多页签工作栏的滚动箭头不再遮挡关闭按钮。DS 网页和服务操作名称更明确,停止确认与取消按钮同行显示,减少输入区跳动。
|
||||
|
||||
### 回顾运行期间的控制
|
||||
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# 0.13.8 起综合宣传稿核查记录
|
||||
|
||||
## 2026-10-05 截至 e50ca6d1 的增量核查
|
||||
|
||||
本轮最终范围 `9527402..e50ca6d1`,共 60 个提交;全文范围为 `v0.13.7..e50ca6d1`。初次固定至 `bbf24c28` 的 58 个提交,收尾补查新产生的两项提交。本地桌面 `v0.15.11` 与 Agent `agent-v0.15.4` 同指向 `78afec77`,上一桌面 `v0.15.10` 指向 `ff61da28`。Telegram、自定义回顾日期、历史耗时缺失提示及最后一组运行修复在 0.15.11 标签之后,均已提交,不将其混入该标签,也不使用开发状态标签。以下取代旧节的当前状态描述,旧节仅作历史依据。
|
||||
|
||||
沿用用户确认的标题与综合稿结构,继续按新增、改进、修复组织。Telegram 与文件导入各设短节,统一执行和项目菜单放在改进段前部;监督、图谱、性能与统计合入原有章节,避免重复介绍。打包、构建、测试及纯架构维护不进入宣传正文。
|
||||
|
||||
| 提交 | 入文内容 | 核查依据与边界 |
|
||||
| --- | --- | --- |
|
||||
| `b4df493b`、`e50ca6d1` | Telegram 首次配置顺序、监督临时失败重试 | 已提交中英文通道文案,先空白 ID 启用保存,再 /whoami 获取自己的 ID,测试连接不启动收信。`supervision-production.ts` 的包装层最多额外一次临时失败重试,429、部分 5xx、明确网络/流中断;排除认证、配置、额度、超时、取消、容量错误。等待可取消,失败用量保留,每次尝试独立超时;不扩大为所有 Runtime 全局重试或严格最多两次 HTTP 请求。替换旧稿网络错误不重试的表述。历史 177 项通过/1 跳过为假 fetch 和 SQLite 验证,非本轮模型实测 |
|
||||
| `cf598121`、`bbf24c28` | Telegram 私聊、原样保存重连 | 已提交 `telegram-channel-driver.ts`、`ChannelSettingsSection.tsx` 与 Telegram PRD/progress。自建单 Bot、数字用户 ID 白名单、通道项目、普通私聊文字输入与最终文字回复,系统代理及恢复;桌面常驻。没有群聊、媒体输入或附件发送,真实 Bot+模型完整联调仍待完成,正文标注 C02 |
|
||||
| `56ebd40b` | 统一执行,删除旧 Ask/Execute 限制 | `model-runtime.ts`、Runtime 接线与 `unified-execution/logic-design.md`。按所选 Runtime、能力及账户权限执行;专用文本分析无工具,各后端支持范围保留。同步修正图谱、Obsidian、原生终端、图片保存、配置工具、继续讨论及文件搜索旧段落 |
|
||||
| `e89db96b`、`658e7f10`、`56ebd40b` | 项目与会话统一菜单 | `WorkspaceMenu.tsx`、`workspace-menu-selectors.ts`。打开默认全部项目,分类搜索、悬停预览、最近 10 个会话与活动、归档恢复、返回上次会话;已完成筛选不受最近 10 条限制。不沿用旧 UI 文档中默认仅预览当前项目的描述 |
|
||||
| `56ebd40b`、`49fd0d30`、`ee186f9f` | 文件导入、时间排序、项目目录 | `workspace-import.ts`、`WorkspaceFilesPanel.tsx`、项目契约及 SSH 目录浏览。本机文件导入本地/远端,同名保留,单项失败继续;远程要求 Agent 0.15.4,每目录排序限已读最多 500 条。新本地项目必选目录,远程支持目录符号链接。成果页退出但记录保留,不自动将历史成果复制到目录 |
|
||||
| `48d7526b`、`2c2b6c31` | 监督专用文本模型与会话图谱开关 | `HeartbeatCenter.tsx`、`supervision-production.ts`、`Composer.tsx`、IPC 和原生客户端。监督模型为全局设置,未指定跟随默认,失效或缺密钥报错。会话开关缺省允许查询,仍受总开关与分配约束;关闭不排除会话被回顾、不删除记录,开启不自动注入上下文 |
|
||||
| `126dbc57` | 自定义最近天数与起止日期 | `SupervisorWorkspace.tsx` 日期计算和提交路径。最近天数按每 24 小时计算;指定日期按本地日界,包含结束日,今天截至提交时刻。普通回顾仍增量,需要重新分析时选择重新整理 |
|
||||
| `577210a8`、`b9186a61`、`ea3d607f` | 共享历史选择、保留未归属事件、螺旋配色与密度 | `story-graph-3d-model.ts`、`StoryGraph3D.tsx`、数据库选定回顾查询和监督界面。所选历史事件与当前故事关系分开;主故事颜色仅覆盖实际事件区间,空档中性;半径按小时消息量和圈时长归一,不代表工时、生产力或小时内分布 |
|
||||
| `4d18794e`、`d0877d92`、`0cd5da33`、`75e50fc1` | 运行耗时、缺失提示、OpenCode 命中率及会话汇总修复 | `assistant-database.ts`、`use-execution-stats.ts`、`token-usage.ts`。模型与工具执行累计,等用户暂停,并行相加;旧历史不补算,缺失零与完整零区分。OpenCode 分母含 input/cacheRead/cacheWrite,重新查询校正,不宣传缓存性能提高;去除前 5,000 会话统计限制 |
|
||||
| `10c51628`、`f4458afe`、`b2eb86a0`、`68cc0792`、`010aafb6`、`41be9f80`、`2e3d2e77`、`651475d1`、`492f1d20` | 文档后台解析、终端合批、列表窗口化、启动与会话刷新 | 文档 worker、终端服务、窗口化列表、Composer、KeepAliveRoute、任务 store。描述减少重复处理的具体场景,不给通用倍数,不宣称所有数据库操作已异步化;任务/成果 store 抽取不单独计新功能 |
|
||||
| `6aab78b9`、`352da66d`、`bf9850a6`、`0dd4d1d6` | 活动记录增量保存与分页、数据库处理 | 活动 store/sync/repository、只读 worker、数据库迁移。Renderer 不再持有整份历史,后台汇总计数,退出裁剪页而不删历史。高频本地 NORMAL 写入的断电持久性边界已加入升级说明,关键生命周期/远程确认等仍保留 FULL |
|
||||
| `805acc3f`、`a620c226`、`bbf24c28` | 长列表阅读状态、笔记抽屉、消息展开保留 | `message-expansion.tsx`、聊天/笔记窗口化与终端路径。展开状态只在历史面板存续期保留;跨屏选择复制、滚动锚点、跳到底部和窄屏笔记入口改善。不承诺跨重启恢复展开状态 |
|
||||
| `bbf24c28` | Runtime、监督、通道故障修复 | `agent-owned-acp-prompt.ts`、`protocol-server.ts`、`opencode-runtime.ts`;远程启动可取消且不阻塞其他会话,本地提交失败及时结束。`supervision-production.ts` 修复旧记录提及的 batch 未转发,发布前暂停与故事目的地校验;旧请求释放后控件可用。通道图片结果校验、钉钉业务错误进入重试。Telegram 仍不发送图片。远程修复需包含此提交的 Agent |
|
||||
| `629e8254` | PPTX OCR 错误来源与位置 | 解析路径及 OCR 错误诊断;实际服务、幻灯片与图片位置。外部服务 HTTP 500 本身未因此修复 |
|
||||
| `55f554d1`、`2adee136`、`78beade2`、`29a134b8`、`74efbcdc`、`0e9c9d49`、`b28ddd4b` | 控件、焦点、DS 操作及设置布局 | 合入界面简述;工作栏箭头不遮关闭按钮,停止确认同行。间距、代码抽取、缓存预算不逐项包装成新能力,b28ddd4b 中上一稿文档不重复计新功能 |
|
||||
| `ae4c6d24` | 旧 Runtime 缓存清理,仅内部记录 | 属于磁盘维护,按用户偏好不写入功能宣传,也不当作通用提速结论 |
|
||||
| `9d03217f`、`10065da8`、`942fe320`、`39fc15ef`、`59d36c22`、`91edcf5e`、`e205e909` | 架构、性能报告和官网同步,仅内部依据 | 性能报告涵盖部分区间外工作,受控测量不能整体归因本轮;官网改文案不列桌面新功能 |
|
||||
| `739f2670`、`6b634c5d`、`5d0ca44b`、`273a7fa7`、`362fe504`、`ff61da28`、`245e296a`、`29af4090`、`4e72aa04`、`78afec77` | lint、测试与发布维护 | 用于版本与历史验证依据,不进入宣传正文 |
|
||||
|
||||
核查终点数据库 schema 61,Runtime 设置 22,应用设置 12,通道设置 3;0.15.11 标签数据库为 60。升级提醒保留完整用户数据、设置、数据库/WAL 和 notes 成套备份,说明大库转换等待与旧客户端回退限制,不承诺固定空间回收比例。版本号文件仍为 0.15.11/0.15.4,不据此推定后续提交已发布。
|
||||
|
||||
开始时工作区已有 Telegram 技术设计和两份 i18n 修改,核查期间又出现其他源码修改;均保留,不作为本次已提交依据。故事图谱检索得到自动归纳的宣传稿经验,提示区分现有能力与预览;实际内容以本次 Git 和生产源码核对为准。
|
||||
|
||||
本轮只做静态事实核查与文档验证,产品测试、构建、真实模型请求和新截图均为 0。提交中的 6136 测试通过、7 次模型调用及 Linux Host 验证属于既有记录,不写成本轮执行;Telegram 完整真实联调另有缺口。现有原型图保持示意标注,生产截图和公开下载信息仍待核验。未新增图片链接,未提交、推送或对外发布。
|
||||
|
||||
独立复核修正了旧稿“任务统计转到后台查询”的过时机制说明,改为当前的按运行状态累计。deai-writing 扫描阻断项 0、复核项 18;保留必要的版本、升级、素材与功能边界说明,以及模型理解能力的限定。文档差异检查通过,仅有既有 LF/CRLF 行尾提示。
|
||||
|
||||
## 2026-10-03 截至 9527402 的增量核查
|
||||
|
||||
本轮固定范围 `b02ce31..9527402`,共 45 个提交;全文范围为 `v0.13.7..9527402`。桌面 `v0.15.9` 与 Agent `agent-v0.15.3` 同指向 `9527402`。沿用用户确认的标题和综合稿结构,直接更新已授权草稿;未在线核验公开安装包和下载信息。以下记录取代旧节中的当前状态描述,旧节保留为历史依据。
|
||||
|
||||
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
|
||||
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-03 更新) | v0.15.9(9527402);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事归类、时间螺旋、经验与心跳建议、助手按需检索、图片保存和长会话性能;原概念图待换为生产截图,公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
| 2026-09-26(10-05 更新) | v0.15.11(78afec77)及之后截至 e50ca6d1;汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补 Telegram 私聊、统一执行、项目会话菜单、文件导入、自定义回顾日期、监督模型与会话图谱开关、临时失败重试、3D 和性能改进;Telegram 实际联调、生产配图及公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
|
||||
|
||||
@@ -14,6 +14,13 @@ requests remain paused during reconnection. Startup preserves closed intervals,
|
||||
discards unclosed intervals, and marks them incomplete; old history is not
|
||||
backfilled by scanning messages or events.
|
||||
|
||||
An incomplete zero duration means no known timed interval, not a measured zero.
|
||||
Both conversation totals and task cards display "unavailable" in this case.
|
||||
Positive partial durations retain the incomplete warning; complete zero durations
|
||||
still display zero. The upgrade regression verifies that a new completed reply
|
||||
in an old conversation retains its accumulated milliseconds after reopening the
|
||||
database. This presentation fix does not restore pre-upgrade historical totals.
|
||||
|
||||
Schema 59 stores timing rows and adds a covering index for conversation
|
||||
summaries. The old historical aggregation, its caches, dedicated reader/worker,
|
||||
worker build entries, and five-second statistics polling have been removed.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# KISS 局部修复与验证
|
||||
|
||||
## 范围
|
||||
|
||||
基线为 `e50ca6d1`。先更新文档,再实测实施,完成后回填证据并提交本轮改动。
|
||||
目标架构见[桌面存储方向](../architecture/desktop-storage-direction.md);独立存储服务迁移留待之后讨论。
|
||||
本轮复用现有 Worker、数据库、IPC 和控件,不全面异步化存储,不更换数据库驱动,不增加通用恢复框架。
|
||||
工作区已有三份宣传稿修改,以及实施期间并行加入的“删除会话后继续回顾”改动,不属于本轮提交;共享文件按片段区分。
|
||||
|
||||
## 实施清单
|
||||
|
||||
| 编号 | 局部改动 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| K01 | 概览不提前加载图谱,来源正文按需读取 | 首次概览无需 graph;图谱和来源内容完整,历史导航正确 | 已实现,组件及 Electron 通过 |
|
||||
| K02 | 概览索引与故事/经验批量、按项目查询 | 查询结果与排序等价;升级保留原数据,实测查询及输出规模 | 已实现,schema 61→62 数据等价验证通过 |
|
||||
| K03 | 故事摘要建索引并只计算一次 | 摘要分类及时间规则不变;暖切换不重复计算 | 已实现,三轮对照及回归通过 |
|
||||
| K04 | 图谱选择列表虚拟化 | DOM 有界,键盘、焦点、选中、滚动和内容访问保留 | 已实现,补齐外部 Tab 入口后 Electron 通过 |
|
||||
| K05 | 监督重读取接入现有只读 Worker | 生产 UI 调用确实离开 Main,读快照一致,失败不静默回退重查询 | 已实现,真实 Worker/IPC 回归通过 |
|
||||
| K06 | 发布只构造一次最终快照 | 完整结果与 checkpoint 原子提交;实测剩余 Main 阻塞,不隐藏未解决预算 | 已实现一次构造;Main 发布仍超预算,待架构迁移 |
|
||||
| K07 | 远程取消清理等待及迟到路由 | 不占全局控制队列,清理后迟到安装不残留,活跃 peer 正常 | 已实现,当前源码 Linux x64 Host 通过 |
|
||||
| K08 | 超容量响应保留已收到模型用量 | 不保留超限正文、不重试容量错误,实际用量不丢失或重复 | 已实现,两个协议及清理期限回归通过 |
|
||||
| K09 | Telegram 启动冲突、投递错误及限流 | 待发回复不因接收冲突永久终止;错误可见;跨层遵守 retry_after | 已实现,loopback/SQLite/Electron 通过 |
|
||||
|
||||
## 已有审查基线
|
||||
|
||||
前三轮合成 SQLite:20 份结果、40,000 条消息、8,000 个事件/来源时,概览同步读取
|
||||
310/328/324 ms;完整图谱 379/383/387 ms,JSON 约 25.74 MB。400 个故事、800 条经验
|
||||
的响应执行 1,204 条 SQL,首次 52~65 ms。100 批次、每批 20 来源的最终发布为
|
||||
567/711/740 ms。该分布是压力样本,不代表用户实际库,首次调用也不等于冷 OS 缓存。
|
||||
|
||||
Renderer 源码 Electron 场景中,2,000 事件产生 8,207 个 DOM 元素,出现 152~314 ms 长任务;
|
||||
2,000 故事各十事件的摘要首次计算 77~81 ms,缓存路由返回仍重复计算。
|
||||
原始基线脚本位于批准的临时目录,后续对照必须固定数据、版本和口径。
|
||||
|
||||
## 验证要求
|
||||
|
||||
- 对功能缺陷先运行失败回归,再修复;不放宽断言、超时或性能预算。
|
||||
- 性能对照至少三轮交替执行,基线用独立临时 worktree;分别记录 Main 阻塞、查询耗时、
|
||||
响应大小、Renderer 长任务及点击反馈,不能把不同探针耗时相加成端到端值。
|
||||
- 数据用隔离合成 SQLite 或一致性副本,不修改 portable 原始库;索引升级遵守迁移指南。
|
||||
- 真实 Electron 验证首次/热切换、历史与详情、键盘、焦点、选中和滚动;不以截断内容提速。
|
||||
- 远程 Runtime 改动在共享 Linux x64 Host 验证当前源码,使用专用目录及进程;记录有界真实模型调用数。
|
||||
- 最后运行完整测试、类型检查和 lint;发布准备不运行本地生产打包或已打包应用探针。
|
||||
|
||||
## 实施与测量结果
|
||||
|
||||
### 监督者读取与界面
|
||||
|
||||
后端保留原业务合同,文件数据库的三个 UI 读取在现有 Worker 的读快照中执行,失败直接报告,不回退 Main 重查询。概览新增匹配索引;故事/经验批量查询按项目筛选;图谱保留所有事件和关联,来源只传已有 schema 所需标题与时间,完整正文仍走来源详情。
|
||||
|
||||
| 测量项 | 基线 | 修复后 |
|
||||
| --- | --- | --- |
|
||||
| 大样本概览:调用方最大循环间隔,三轮 | 389/471/873 ms | 2.45/2.10/3.17 ms |
|
||||
| 大样本图谱:调用方最大循环间隔,三轮 | 583/572/646 ms | 12.04/12.29/15.79 ms |
|
||||
| 故事/经验响应的 SQL 数 | 1,204 | 6 |
|
||||
| 大样本图谱 JSON 大小 | 25,742,067 字节 | 7,143,667 字节 |
|
||||
| 2,000 事件图谱点击至两帧反馈 | 188~346 ms | 24~39 ms |
|
||||
| 同场景挂载列表行/文档元素 | 2,000/8,207 | 24/329(首轮窗口实现) |
|
||||
| 2,000 故事各十事件的首次摘要 CPU | 48~80 ms | 9~16 ms |
|
||||
| 缓存路由返回的摘要 CPU | 43~74 ms | 0 ms |
|
||||
| 100 批次发布的同步耗时 | 686/729/730 ms | 271/286/303 ms |
|
||||
| 100 批次的快照 UPDATE 数 | 101 | 1 |
|
||||
|
||||
数据来自固定基线 `e50ca6d1` 与修复版三轮对照,平台为 Windows x64、Core Ultra X7 358H、Electron 44.5.1。
|
||||
存储测量使用真实合成 SQLite 和 Worker,界面测量使用生产 React 组件与模拟 bridge,二者没有相加成端到端时间。
|
||||
发布复测包含工作树并行删除校验,不能作为本轮独立提交的精确耗时保证。迁移前后 55 张表、32,947 行含 rowid 的哈希一致,外键及完整性检查通过,未执行正文转换或回收。
|
||||
|
||||
独立复审后,故事读取增加 scope/刷新版本所有权校验,避免旧编辑完成时使新读取失效。
|
||||
虚拟列表额外保留逻辑首末行,保证从外部 Tab/Shift+Tab 进入时仍按完整顺序访问;最终挂载量比上述首轮测量最多多两行,后续 Electron 继续检查有界性与可见焦点。共享列表的主动滚离焦点行为保持不变。
|
||||
|
||||
新 Electron 回归还暴露既有消息展开测试的等待缺陷:语言切换后等到了旧节点,且帧轮询回调异常会留下未结算 Promise。测试现等待替代请求的最终渲染,帧回调异常正常拒绝;原超时及断言不放宽,相同严格场景在基线与当前源码均通过。
|
||||
|
||||
完整后端和前端逐轮数据分别见[后端记录](../features/conversation-supervision/progress.md#2026-10-05-监督页面读取与发布局部优化)和[界面记录](../features/conversation-supervision/frontend-progress.md)。
|
||||
|
||||
### 运行时与交付
|
||||
|
||||
K07 的路由 HTTP 清理不再占用全局控制队列;迟到安装按原 Session/operation 清除,不影响活跃 peer。
|
||||
当前源码通过共享 Linux x64 的 OpenCode 与 Continue:peer 查询分别 13/46 ms,取消完成分别 176/194 ms;工具、断开恢复、测试 Agent 提升及事件重放均通过。本轮准确为 **8 次外部文本 HTTP 请求、0 次图片调用**,含首次测试触及旧请求数上限前的两次请求;各运行结束时所属进程和目录残留均为零。详见[运行时记录](../features/unified-execution/progress.md#2026-10-05-k07k08-局部修复)。
|
||||
|
||||
K08 超容量后立即中止并清空正文,最多消费一秒用量事件,保留原错误且不重试。OpenAI/Anthropic、已缓冲完成响应和不响应取消的迭代器均有回归。
|
||||
|
||||
K09 已验证启动 webhook 冲突时仍可发送已验证身份匹配的待发结果;发送错误可见且不覆盖接收错误;最后一次 429 的等待可取消并跨越驱动/发件箱边界。
|
||||
Telegram 验证只使用 loopback HTTP、合成 SQLite 和真实 Electron 设置入口,没有向真实收件人发消息。详见[通道记录](../features/telegram-channel/progress.md)。
|
||||
|
||||
### 最终检查
|
||||
|
||||
将本轮 59 个路径的暂存内容应用到 `e50ca6d1` 的隔离工作树,排除并行删除功能和宣传稿后执行完整验收。验证时暂存树为 `31e6199b91cb703ddffa3806807326143064a7f1`;随后只补充文档中的验收结果,没有再改代码。暂存代码、测试与独立工作树逐文件核对一致。
|
||||
|
||||
| 命令 | 最终结果 |
|
||||
| --- | --- |
|
||||
| `npm test` | 523 文件通过、15 跳过;6,212 用例通过、0 失败、87 跳过,832.51 秒 |
|
||||
| `npm run typecheck` | Main、Agent、Renderer 全部通过,7.36 秒 |
|
||||
| `npm run lint` | 通过,24.24 秒 |
|
||||
| `git diff --cached --check` | 通过 |
|
||||
|
||||
完整测试使用本机缓存的 Runtime 二进制及配置,包含原生 OpenCode、监督者布局、消息展开和 Telegram Electron 场景;未开启外部模型验收开关。首次隔离测试因缺少 Runtime 缓存多跳过 13 项,不作为最终结果;补齐已有缓存后完整重跑得到上表计数。并行实施期间的其他失败轮次保留在各组记录中,不与最终结果混算。
|
||||
|
||||
日志、JSON 测试结果、跳过清单、暂存快照及文件哈希位于批准临时目录 `opencode/kiss-staged-validation-Bmc3dm`,以 `final-verification.json`、`snapshot.json` 和 `test.log` 为索引。验证前后原工作区 77 个已变更文件逐字节未变;未暂存的并行修改完整保留。本轮没有生产打包、推送或发布。
|
||||
|
||||
## 剩余限制
|
||||
|
||||
- 大批量发布仍在 Main,约 271~303 ms,未达到 16 ms 预算;保留原事务及持久化语义,后续讨论迁移。
|
||||
- 故事完整响应仍约 3.32 MB,曾有一次 19.74 ms 的调用方间隔;图谱正文统计在 Worker 仍需数百毫秒。
|
||||
- 未预载的源码模块首次进入仍约 0.31~0.34 秒;一轮小图谱首开出现 55 ms 长任务,没有宣称所有预算达标。
|
||||
- 未覆盖完整生产窗口 IPC 的端到端性能、低端机节流、macOS/ARM64、签名包安装或真实 Telegram 平台收发。
|
||||
- schema 62 按既有规则禁止旧客户端打开;独立存储服务、全仓异步化和发布版本号调整均不属于本轮。
|
||||
@@ -1,5 +1,7 @@
|
||||
# 桌面端性能改造(第一期,参照 VS Code 架构)结项报告
|
||||
|
||||
2026-10-05 校正:本文保留第一期测量截面,开头结论只适用于当时已测场景。生产监督者首次进入和最终发布未被这些场景覆盖,后续实测发现阻塞并开展[局部修复](./kiss-local-fixes-2026-10-05.md)。`perf:check` 的历史回归上限也不等于性能原则的全部预算。
|
||||
|
||||
- **时间:** 2026-10-02 至 2026-10-04
|
||||
- **范围:** Desktop 的 Electron Main、Preload、Renderer 及其后台线程。远程主机(Agent、SSH、远程 Runtime)的稳定性和性能不在本期范围,下一期单独审查。
|
||||
- **结论:** 本期计划内的工作已完成。用户日常能感受到的卡顿(打字、流式输出、切换会话、滚动、启动、写库)都已测量并修复。剩下的工作是架构上的长期项(存储接口异步化、独立数据进程),风险高,已经写明理由,留到后续单独立项。
|
||||
@@ -72,7 +74,7 @@ Main 卡顿的数据来自真实数据,以及 5 万个文本块的知识库。
|
||||
| Markdown 解析放到后台线程 | 需要重写渲染流程。标准机上流式输出已经不卡,只有低端机上的长回答还有余量 |
|
||||
| 窗口提前加载,接口请求排队等待 | 可省 200–300 ms,但要改动所有接口的注册顺序和首次运行流程 |
|
||||
| 会话队列移出 App | 和发送、恢���、中断联系太紧,拆出来的风险大于收益 |
|
||||
| 性能阈值接入 CI | 仓库目前没有 CI 配置 |
|
||||
| 性能阈值接入 CI | 当期未将性能基准接入已有 CI;原文“仓库没有 CI 配置”有误 |
|
||||
|
||||
## 4. 已知问题和下一步
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 阶段审计修复与验收
|
||||
|
||||
本轮十项审计发现均已修复,最终完整测试、类型检查和 lint 通过。修改基于 `126dbc57` 后的工作树,未提交或发布。数据验证使用隔离的合成 SQLite 和本轮真实 Runtime 产生的事件,没有改写 portable 原始用户数据。
|
||||
|
||||
## 修复清单
|
||||
|
||||
| 编号 | 问题 | 必须覆盖的失败场景 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| A01 | 远程 ACP 启动占用全局控制队列 | 延迟初始化时其他绑定查询、取消、看门狗和关闭仍可完成 | 已修复;回归与真实 Host 通过 |
|
||||
| A02 | 本地 OpenCode 提交失败后等待静默 SSE | 拒绝、错误响应、提交超时均及时结束并保留错误,正常流不变 | 已修复;回归与真实本机模型通过 |
|
||||
| A03 | 监督候选生产链路遗漏项目参数 | 真实生产装配与 Worker 路径尊重 projectId/crossProject | 已修复;生产 Worker、SQLite 与真实模型通过 |
|
||||
| A04 | 最终候选查询期间暂停仍发布 | 暂停后不保存结果,正常完成仍保存 | 已修复;暂停/继续与取消回归通过 |
|
||||
| A05 | 在途故事分配写入已移除故事 | 移除候选后不得产生隐藏关联或消耗待处理事件 | 已修复;移除/合并/拆分回归通过 |
|
||||
| A06 | 图片生成结果被通道严格契约拒绝 | 含 artifactIds 的执行结果通过生产通道交付,桌面产物保留 | 已修复;生产服务与 SQLite 发件箱通过 |
|
||||
| A07 | Telegram 原样保存不能恢复轮询 | 终止错误解除后原样保存重启,正常运行保存不重复启动 | 已修复;组件与 Electron 生产入口通过 |
|
||||
| A08 | 钉钉 HTTP 200 业务错误被算作送达 | 非零 errcode 保留失败及重试上下文,成功回复正常交付 | 已修复;loopback HTTP 与 SQLite 重试通过 |
|
||||
| A09 | 监督视图请求失效后永久忙碌 | 刷新期间旧请求结束不覆盖新状态,控件恢复可用 | 已修复;组件与 Electron 通过 |
|
||||
| A10 | 虚拟消息行淘汰丢失展开状态 | 超过八行交互后滚出再滚回保持展开,DOM 仍有界 | 已修复;组件、Electron 与缓存保留测量通过 |
|
||||
|
||||
## 验收要求
|
||||
|
||||
- 每项记录旧实现失败、新实现通过的测试证据及命令。
|
||||
- 运行时改动检查本地与远程路径,Agent 改动在共享 Linux x64 Host 验证当前源码。
|
||||
- 真实模型请求保持有界,记录实际请求数;模拟 Provider 和真实 Provider 分开报告。
|
||||
- 数据验证使用真实 SQLite 合成数据或 portable 数据隔离副本,记录规模及一致性检查,不记录用户正文或凭据。
|
||||
- 性能检查遵守 `docs/architecture/performance-principles.md`,测量与推断分开;不删除动画、虚拟化、取消或正常功能来取得通过。
|
||||
- 最后运行完整测试、类型检查与 lint,并同步所属功能文档。
|
||||
|
||||
## 验证记录
|
||||
|
||||
### 复现与修复
|
||||
|
||||
| 范围 | 修复前证据 | 修复及验证 |
|
||||
| --- | --- | --- |
|
||||
| A01/A02 | 静默 SSE 三种提交失败不结束;挂起启动阻塞取消和其他绑定;协议分派只收到 start | 启动等待释放 backend 和 socket 分派队列;相同启动合并、冲突拒绝;迟到 Session 单独关闭。本机提交失败取消所属订阅并保留原错误。九项新增回归通过 |
|
||||
| A03/A04/A05 | 八个新增用例中七个失败,既有取消场景原本通过;关闭跨项目仍收到其他项目候选,暂停后仍发布,已移除故事仍收到新关联 | 贯通批次参数;发布前检查取消;原事务中检查目标当前状态。96 项监督/Worker 定向测试通过,无 schema 迁移 |
|
||||
| A06/A07/A08 | 选择性回归九项失败、三项通过;图片结果失败、原样保存不调用重启、业务错误被记作送达 | 通道边界映射公开结果;异常轮询状态允许原样保存重启;有界解析钉钉业务响应。106 项通道测试、3 项 IPC 测试及 Telegram Electron 通过 |
|
||||
| A09/A10 | 控件保持禁用;超过八行交互后的展开状态丢失,Electron 原生交互也复现 | 失效请求释放等待状态;展开选择由历史面板保存,虚拟行卸载后可恢复。112 项相关组件回归及真实 Electron 通过 |
|
||||
|
||||
独立复审没有发现运行时修复引入的高置信度回归,但发现首次 A10 实现保存了未操作的默认展开值。跟进回归复现浏览 10,000 个作用域留下 10,000 条记录,现改为仅保存用户覆盖项并清理已删除来源;该修正包含在最终全仓测试中。
|
||||
|
||||
### 真实模型与 Host
|
||||
|
||||
本轮准确合计 **7 次外部文本模型请求、0 次图片模型请求**。另有 Telegram Electron 中的 2 次 loopback 模型请求,不计为外部请求。没有向真实消息通道收件人发送测试消息。
|
||||
|
||||
| 路径 | 真实外部请求 | 结果 |
|
||||
| --- | ---: | --- |
|
||||
| Linux x64 OpenCode | 2 | 当前源码工具执行、断开、恢复、测试 Agent 提升和事件重放通过;启动挂起时查询 48 ms、取消 216 ms |
|
||||
| Linux x64 Continue | 2 | 同类生产路径通过;启动挂起时查询 60 ms、取消 258 ms |
|
||||
| Windows 本地 OpenCode | 1 | 真实二进制正常返回固定文本,0 工具调用,总耗时 4,727 ms |
|
||||
| 监督生产服务与文件 Worker | 2 | 147 字符合成消息完成提取和故事归属,总耗时 3.84 秒;无变化重跑新增请求为 0 |
|
||||
|
||||
Host 使用现有固定身份和加密凭据,测试资源位于 `/root/tmp/gb-lifecycle-*`。结束时所属进程为零,没有替换原 Host 安装。延迟是单次场景观察,不是 p95 或吞吐结论。完整命令、脚本及日志索引见[运行时验证](../features/unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。
|
||||
|
||||
### 数据与性能
|
||||
|
||||
- SQLite 合成库包含 10 个项目、1,000 个实体、10,000 个事件和 10,000 条事件实体关联。候选 ID 与排序逐项一致,数据计数不变、外键检查为空、完整性为 `ok`。真实 Host 事件另外通过生产数据库与批处理器写入:OpenCode 45 条/23 个 checkpoint,Continue 16 条/8 个 checkpoint,重放去重通过。
|
||||
- 激活项目隔离后,测量发现原相关子查询较慢,改用实体集合查询。三轮各十次项目查询,旧 SQL 合计 3,517/5,302/4,190 ms;新 Worker 路径含传输为 39.18/34.01/36.04 ms。120 事件归属事务为 6.63 ms。此结果仅适用于该合成库 SQL 对照,见[监督后端证据](../features/conversation-supervision/progress.md#2026-10-05-后端阶段审查-a03a04a05)。
|
||||
- Windows Electron 使用 2,000/10,000 条合成消息,原版与首次修复各三轮交替运行;后续缓存修正又做三轮对照。最大采样挂载量均为 15 个消息行、926 个 DOM 元素,展开、键盘、焦点、选区和滚动回归通过。缓存修正后的导航中位数为 38.5~39.1 ms,含聚焦后的两帧等待,不能作为纯渲染耗时或整体预算结论。
|
||||
- 未操作导航后缓存条目为零;13 条消息交互后保留 38 条用户覆盖项,删除消息后归零。该测量统计缓存条目,没有测量堆字节。方法、每轮数据及原有滚动回归见[前端验证](../features/application-tool-navigation/progress.md#2026-10-05展开状态缓存复查)。
|
||||
|
||||
### 最终检查
|
||||
|
||||
| 命令 | 最终结果 |
|
||||
| --- | --- |
|
||||
| `npm test` | 518 文件通过、15 文件跳过;6,136 项通过、0 失败、87 跳过;852.63 秒 |
|
||||
| `npm run typecheck` | Main、Agent、Renderer 全部通过 |
|
||||
| `npm run lint` | 通过 |
|
||||
| `git diff --check` | 通过 |
|
||||
|
||||
首轮完整测试的唯一失败为既有 `cleanup-timeout`。独立 GC 探针确认 mock 返回永不结算 Promise,却未保留待完成的 Request;GC 后 Undici 取消转发失效。测试夹具现保持 Request 存活至取消,并按真实 fetch 行为拒绝。原断言、调用顺序和一秒清理预算均保留,新增强制 GC 回归先在旧 mock 上失败,再在修正后通过。生产清理代码未因此调整。详见[GC 复查](../features/unified-execution/progress.md#sdk-cleanup-timeout-复查)。
|
||||
|
||||
最终全仓原始日志为 `C:/Users/jiang/AppData/Local/Temp/opencode/a02-cleanup-final-20261005.log`,退出码及时间记录为同名 `.json`。通道网络替身、发件箱及图片元数据验证见[通道记录](../features/telegram-channel/progress.md#2026-10-05-通道审计修复验证)。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本轮未做真实 Telegram/钉钉平台收发或付费图片生成,A06 验证的是生成完成后的生产交付契约。未做 macOS、Linux arm64、低端机节流、完整 App 性能预算或签名包安装验收,也未用用户生产大库证明性能。上述已测场景未观察到功能或性能退化,不能推广为所有设备和数据规模的保证。远程修复生效需要部署包含当前源码的 Agent;本轮没有提交、打包发布或升级用户安装。
|
||||
@@ -1019,6 +1019,10 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
|
||||
### PERF-15 存储接口异步化
|
||||
|
||||
2026-10-05 范围澄清:目标职责与迁移边界见[桌面存储方向](../architecture/desktop-storage-direction.md)。
|
||||
当前先实施[监督者及执行路径的 KISS 局部修复](../review/kiss-local-fixes-2026-10-05.md),
|
||||
完成、验证并提交后再讨论全面迁移。局部 Worker 复用不等于 PERF-15/16 已完成。
|
||||
|
||||
- **优先级 / 状态:** P1 / 进行中(热点读走 worker;高频写降为 NORMAL、checkpoint 移出 Main、启动恢复按索引,见 7.2.2;接口异步化未开始)
|
||||
- **范围:** 在原进程内把 `AssistantDatabase`、`KnowledgeDatabase` 等对外接口改为异步,
|
||||
调用方全部 `await`;会话列表只返回摘要,搜索下推到 SQL,活动记录改为增量追加。
|
||||
@@ -1030,9 +1034,10 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
|
||||
- **优先级 / 状态:** P1 / 待开始
|
||||
- **实施条件:** `PERF-15` 完成,调用方已全部异步。
|
||||
- **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph
|
||||
(含 Agent 主动调用的检索)、远程 Runtime 事件持久化、活动记录及文档解析;Main 只转发;
|
||||
保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。
|
||||
- **范围:** 目标为统一管理业务 SQLite 的 `utilityProcess`,承接会话、知识库、Story Graph、
|
||||
远程 Runtime 事件持久化和活动记录的业务事务;Main 只做校验与转发。重查询由受管只读 Worker
|
||||
分担,解析、OCR 与 Runtime 执行保持独立,每个数据库明确一个写入所有者;不合并现有数据库。
|
||||
职责、生命周期及 VS Code 对照以[桌面存储方向](../architecture/desktop-storage-direction.md)为准。
|
||||
- **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、
|
||||
崩溃恢复和迁移语义不回退。
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.11",
|
||||
"version": "0.15.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.11",
|
||||
"version": "0.15.12",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.11",
|
||||
"version": "0.15.12",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# GoodBuddy Agent 0.15.5 更新内容
|
||||
|
||||
## 问题修复
|
||||
|
||||
- **原生启动期间仍可取消和查询**:OpenCode 或 Continue 初始化等待不再占住全局控制队列,其他绑定、取消和看门狗可以继续处理。同一启动保持合并与冲突校验,取消后的迟到 Session 单独关闭,不重发原 Prompt。
|
||||
- **模型路由清理不阻塞其他任务**:取消时的路由清理等待不再阻塞全局控制操作,迟到安装按所属 Session 和 operation 清除,不影响仍在运行的其他会话。真实 Linux x64 Host 已验证两个 Runtime 的工具、取消、断开恢复及回放去重,不代表所有平台和恢复场景均已覆盖。
|
||||
|
||||
## 升级须知
|
||||
|
||||
先升级至 **Desktop 0.15.12**,再下载此包并更新 Host。已有进程不会热更新,请先妥善处理活动任务;runtime/acp 6、wire 2.0、Node 24.19.0、OpenCode 1.18.29 和 Continue 1.5.47 保持不变。
|
||||
|
||||
# What's New in GoodBuddy Agent 0.15.5
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Keep cancellation and queries responsive during native startup**: Waiting for OpenCode or Continue initialization no longer holds the global control queue, so other bindings, cancellation, and watchdogs can progress. Duplicate startup coalescing and conflict checks remain, and late sessions after cancellation are closed separately without replaying the original prompt.
|
||||
- **Avoid blocking other tasks during model-route cleanup**: Cancellation no longer holds global control operations while waiting for route cleanup, and late installations are removed for their owning session and operation without affecting active peers. Real Linux x64 Host checks covered both Runtimes' tools, cancellation, reconnection, and replay deduplication, not every platform or recovery scenario.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
Upgrade to **Desktop 0.15.12** before downloading this package and updating the Host. Running processes are not hot-updated, so handle active tasks first; runtime/acp 6, wire 2.0, Node 24.19.0, OpenCode 1.18.29, and Continue 1.5.47 remain unchanged.
|
||||
@@ -1,6 +1,52 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.15.12",
|
||||
"releasedAt": "2026-10-05",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**新增 Telegram 私聊通道,真实平台联调待完成**:可配置自建 Bot 的 Token 和数字用户白名单,通过通道项目处理文字请求并回复,使用 Electron 网络以支持系统代理。可先留空发送者 ID、启用并保存,再向 Bot 发送 `/whoami` 获取自己的 ID 后重新保存;连接测试不等于开始接收消息,真实 Bot 与模型完整收发仍待验证。",
|
||||
"**按自定义时间回顾工作**:除最近 1、7、30 天,还可输入正整数天数或指定本地开始、结束日期。指定日期包含结束日全天,今天截到提交时刻,并校验未来日期及逆序区间;普通回顾仍跳过已处理且未变化的来源,重新整理需明确确认。"
|
||||
],
|
||||
"fixes": [
|
||||
"**减少监督者打开和切换时的等待**:概览不再提前加载完整图谱,来源正文按需读取,页面重查询交给现有 Worker,故事摘要复用结果,长选择列表只挂载附近条目。保留来源详情、键盘、焦点和历史选择;大型回顾最终发布仍可能短暂阻塞主进程,不宣称所有性能预算已达标。",
|
||||
"**删除会话后继续未发布的回顾**:已删除会话涉及的未发布批次和依赖汇总会失效,混合批次按剩余来源重算,其余来源继续,并显示省略数量。历史已发布结果保持不变,全部来源删除时不生成空报告;已发送给模型的内容无法撤回,剩余来源修改或归档仍会要求处理冲突。",
|
||||
"**修正回顾隔离、暂停与模型失败处理**:关闭跨项目时按当前项目读取候选,暂停后不再因晚到查询继续发布,故事分配拒绝已移除或合并的目标。临时网络错误、限流及部分服务端错误最多额外重试一次,保留已报告用量;认证、配置、取消、超时和容量错误不因此重试,额外尝试可能增加费用。",
|
||||
"**让 Runtime 失败和取消及时结束**:远程原生启动和模型路由清理不再占住全局控制队列,其他会话的查询、取消和看门狗仍可推进,并清除迟到路由。修复本地 OpenCode 提交失败后持续等待的问题,保留原错误;远程修复需更新 Agent 0.15.5。",
|
||||
"**保留通道待发结果并显示交付错误**:修复生成图片结果在通道契约处被拒、钉钉 HTTP 成功但业务失败被误记为送达的问题。Telegram 异常后原样保存可重启接收,轮询冲突时保留身份匹配的待发回复,发送错误可见,限流等待跨重试层保留并可取消。",
|
||||
"**保留长会话展开状态并区分缺失计时**:浏览长会话时,展开过的详情在滚出再滚回后保持,仅保存用户修改过的状态,删除来源后清理。历史执行时长缺失不再显示成零,旧记录不通过扫描历史补算。"
|
||||
],
|
||||
"notices": [
|
||||
"首次启动升级到 schema 62:schema 61 扩展 Telegram 通道约束,schema 62 增加监督查询索引,不改写用户正文。升级前请关闭应用并备份完整数据目录,至少包括数据库、相邻 `notes/` 和设置文件;大库建索引可能耗时,旧客户端回退需恢复升级前备份。",
|
||||
"远程修复请先升级 Desktop 0.15.12,再下载 Agent 0.15.5 并更新 Host;已有进程不会热更新,更新前妥善处理活动任务。runtime/acp 6、Electron、Node、OpenCode 和 Continue 的固定版本不变。",
|
||||
"继续使用无 Ask/Execute 模式的统一执行,请求按启用能力及执行账号权限运行。Telegram 真实平台完整联调、跨平台性能和独立存储服务迁移不属于已完成验证;高频本地写入继续沿用 NORMAL 的断电持久性取舍。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Add Telegram private messaging, with live-platform acceptance pending**: Configure a self-created bot token and numeric sender allowlist to handle text requests through channel projects and send replies, using Electron networking for system proxy support. Sender IDs can initially be blank: enable and save, send `/whoami` to the bot, then save your ID; connection testing is not message reception, and complete real-bot-plus-model delivery remains unverified.",
|
||||
"**Review a custom time range**: Alongside the last 1, 7, or 30 days, enter a positive number of days or choose local start and end dates. Date ranges include the whole end date, cap today at submission time, and reject future or reversed dates; ordinary reviews still skip unchanged processed sources, while reanalysis requires explicit confirmation."
|
||||
],
|
||||
"fixes": [
|
||||
"**Reduce waiting when opening and switching Supervisor views**: Overview no longer preloads the complete graph, source text loads on demand, heavy page reads use the existing Worker, story digests are reused, and long selection lists mount nearby rows. Source details, keyboard access, focus, and history selection remain available; large final review publication can still briefly block Main, so not all performance budgets are claimed met.",
|
||||
"**Continue unpublished reviews after deleting conversations**: Unpublished batches and dependent summaries containing deleted conversations become invalid, mixed batches recompute surviving sources, and remaining work continues with an omitted-source count. Published historical results remain unchanged and deleting all sources produces no empty report; already-sent model input cannot be recalled, and changes or archival of surviving sources still require conflict handling.",
|
||||
"**Correct review isolation, pause, and model failure handling**: Candidate reads honor the current project when cross-project work is disabled, late queries cannot publish after pause, and story assignment rejects removed or merged destinations. Transient network errors, rate limits, and selected server failures allow at most one additional attempt while retaining reported usage; authentication, configuration, cancellation, timeout, and capacity errors do not gain this retry, and extra attempts can add cost.",
|
||||
"**Finish Runtime failures and cancellation promptly**: Remote native startup and model-route cleanup no longer hold the global control queue, allowing other conversations, cancellation, and watchdogs to progress while removing late routes. Local OpenCode submission failures now end the wait and preserve their original errors; remote fixes require Agent 0.15.5.",
|
||||
"**Preserve pending channel replies and expose delivery errors**: Generated-image results no longer fail the channel contract, and DingTalk business failures in HTTP-success responses are no longer marked delivered. Saving unchanged Telegram settings can restart failed reception, polling conflicts retain identity-matched pending replies, send errors remain visible, and cancellable rate-limit waits survive retry-layer boundaries.",
|
||||
"**Retain expanded conversation details and distinguish missing timing**: Details expanded in long conversations stay expanded after scrolling away and back, storing only user overrides and clearing deleted sources. Missing historical execution duration is no longer displayed as zero, and old timing is not reconstructed by scanning history."
|
||||
],
|
||||
"notices": [
|
||||
"First launch upgrades to schema 62: schema 61 extends Telegram channel constraints and schema 62 adds Supervisor lookup indexes without rewriting user text. Close the app and back up the complete data directory, including at least the database, adjacent `notes/`, and settings files; indexing large databases may take time, and downgrading requires the pre-upgrade backup.",
|
||||
"For remote fixes, upgrade to Desktop 0.15.12, then download Agent 0.15.5 and update the Host; running processes are not hot-updated, so handle active tasks before updating. runtime/acp 6 and pinned Electron, Node, OpenCode, and Continue versions remain unchanged.",
|
||||
"Unified execution remains in place without Ask/Execute modes, using enabled capabilities and execution-account permissions. Full live Telegram acceptance, cross-platform performance, and an independent storage-service migration are not completed validation; frequent local writes retain the NORMAL power-loss durability tradeoff."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.11",
|
||||
"releasedAt": "2026-10-04",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Synthetic SQLite / bounded live validation for A03-A05. Never opens a user database.
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { build } from 'esbuild'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { AssistantDatabase } from '../src/main/assistant/assistant-database'
|
||||
import { createProductionSupervisorService } from '../src/main/assistant/supervision-production'
|
||||
import { SupervisionModelPool } from '../src/main/assistant/supervision-model-pool'
|
||||
import { ModelAgentRuntime } from '../src/main/agent/model-runtime'
|
||||
import type { SupervisionRunRequest } from '../src/shared/supervision-contracts'
|
||||
import { supervisionReviewSettingsSchema } from '../src/shared/supervision-review-contracts'
|
||||
|
||||
async function main() {
|
||||
const [mode, parent, envPath] = process.argv.slice(2)
|
||||
assert((mode === 'scale' || mode === 'live') && parent)
|
||||
const rel = relative(process.cwd(), resolve(parent))
|
||||
assert(rel.startsWith('..') || isAbsolute(rel), 'Output must be outside repository')
|
||||
const directory = await mkdtemp(join(resolve(parent), 'supervision-audit-'))
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const worker = join(directory, 'readonly-query-worker.cjs')
|
||||
await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: worker,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||
const db = new AssistantDatabase(path)
|
||||
db.initialize(directory)
|
||||
db.enableReadonlyWorker(worker)
|
||||
const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase()
|
||||
const pool = new SupervisionModelPool()
|
||||
const report: Record<string, unknown> = { mode, directory, externalCalls: 0 }
|
||||
let calls = 0
|
||||
const started = performance.now()
|
||||
const request: SupervisionRunRequest = { trigger: 'manual', scope: { kind: 'global' },
|
||||
timeRange: { from: '2026-10-01T00:00:00.000Z', to: '2026-10-02T00:00:00.000Z' } }
|
||||
const empty = { summary: 'Synthetic audit', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
try {
|
||||
if (mode === 'scale') {
|
||||
const projects = [db.listProjects()[0]!, ...Array.from({ length: 9 }, (_, i) => db.createProject({ name: `Synthetic ${i}`, description: '', rootPath: directory }))]
|
||||
for (const project of projects) {
|
||||
db.saveSupervisionResult({ request, evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(),
|
||||
title: 'Synthetic ownership', content: 'Synthetic source', occurredAt: request.timeRange.from, locator: { projectId: project.id } }],
|
||||
output: { ...empty,
|
||||
entities: Array.from({ length: 100 }, (_, i) => ({ id: `e${i}`, label: `${project.id}:${i}`, description: 'Synthetic knowledge', sourceReferenceIds: ['s'] })),
|
||||
events: Array.from({ length: 1000 }, (_, i) => ({ title: `Event ${i}`, description: 'Synthetic decision', occurredAt: request.timeRange.from,
|
||||
eventType: 'discussion' as const, entityIds: [`e${i % 100}`], sourceReferenceIds: ['s'] })) } })
|
||||
}
|
||||
report.seedMs = performance.now() - started
|
||||
const counts = () => ['supervision_entities', 'supervision_events', 'supervision_event_entities', 'supervision_sources'].map(table =>
|
||||
[table, Number(sql.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n)])
|
||||
const before = counts()
|
||||
await db.supervisionCandidates(request, { projectId: projects[0]!.id, crossProject: false })
|
||||
const legacy = sql.prepare(`SELECT e.id FROM supervision_entities e WHERE e.confirmation_state != 'revoked' AND EXISTS (
|
||||
SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id
|
||||
WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?)
|
||||
ORDER BY e.updated_at DESC, e.id LIMIT 100`)
|
||||
const rounds: Array<{ legacyMs: number; isolatedMs: number; crossProjectMs: number }> = []
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const legacyStart = performance.now()
|
||||
const expected = new Map(projects.map(project => [project.id, legacy.all(project.id).map(row => row.id)]))
|
||||
const legacyMs = performance.now() - legacyStart
|
||||
const start = performance.now()
|
||||
for (const project of projects) {
|
||||
const candidates = await db.supervisionCandidates(request, { projectId: project.id, crossProject: false })
|
||||
assert.equal(candidates.length, 100)
|
||||
assert(candidates.every(item => item.label.startsWith(project.id + ':')))
|
||||
assert.deepEqual(candidates.map(item => item.id), expected.get(project.id))
|
||||
}
|
||||
const isolatedMs = performance.now() - start
|
||||
const crossStart = performance.now()
|
||||
const candidates = await db.supervisionCandidates(request, { projectId: projects[0]!.id, crossProject: true })
|
||||
assert.equal(candidates.length, 100)
|
||||
rounds.push({ legacyMs, isolatedMs, crossProjectMs: performance.now() - crossStart })
|
||||
}
|
||||
assert.deepEqual(counts(), before)
|
||||
report.rows = Object.fromEntries(before)
|
||||
report.rounds = rounds
|
||||
report.isolatedQueriesPerRound = projects.length
|
||||
// Exercise the new destination check with the largest admitted assignment chunk.
|
||||
sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events')
|
||||
const project = projects[0]!
|
||||
const events = sql.prepare('SELECT id, project_id, started_at, event_type, title, description FROM supervision_events WHERE project_id = ? LIMIT 120').all(project.id)
|
||||
const stories = db.supervisionStories()
|
||||
const applyStart = performance.now()
|
||||
stories.apply(project.id, events as Parameters<typeof stories.apply>[1], [], {
|
||||
stories: [{ key: 'new_1', level: 'feature', name: 'Synthetic feature', description: '' }],
|
||||
assignments: events.map((_, i) => ({ event: `e_${i + 1}`, story: 'new_1' })), concluded: [], links: []
|
||||
}, { crossProject: false, threadEvents: 500 })
|
||||
report.assignment120Ms = performance.now() - applyStart
|
||||
assert.equal(stories.list({ kind: 'projects', projectIds: [project.id] })[0]!.events.length, 120)
|
||||
} else {
|
||||
assert(envPath, 'Environment file required')
|
||||
const env = parseEnv(await readFile(envPath, 'utf8'))
|
||||
assert(env.DEEPSEEK_API_KEY && env.DEEPSEEK_BASE_URL && env.DEEPSEEK_MODEL)
|
||||
const content = 'We decided to use SQLite transactions to save review facts and coverage together. This prevents partial publication. The implementation is pending.'
|
||||
db.saveLocalConversations([{ header: { id: randomUUID(), projectId: db.listProjects()[0]!.id, title: 'Synthetic review decision', updatedAt: Date.parse(request.timeRange.from) },
|
||||
messages: [{ id: randomUUID(), role: 'user', state: 'complete', content, createdAt: Date.parse(request.timeRange.from) }] }])
|
||||
const runtime = new ModelAgentRuntime({ protocol: 'openai-chat-completions', authentication: 'api-key',
|
||||
baseUrl: env.DEEPSEEK_BASE_URL, model: env.DEEPSEEK_MODEL, apiKey: env.DEEPSEEK_API_KEY, defaultWorkspace: directory,
|
||||
maximumOutputTokens: 1800, requestBody: { thinking: { type: 'disabled' }, response_format: { type: 'json_object' } },
|
||||
toolProvider: { listTools: async () => [], getApproval: () => { throw new Error('Tools disabled') },
|
||||
callTool: async () => { throw new Error('Tools disabled') }, releaseConversation: async () => undefined, dispose: async () => undefined },
|
||||
fetcher: async (input, init) => {
|
||||
assert(calls < 3, 'Three external request cap')
|
||||
assert.equal(JSON.parse(String(init?.body)).tools?.length ?? 0, 0)
|
||||
calls++
|
||||
return fetch(input, init)
|
||||
} })
|
||||
const service = createProductionSupervisorService(db, async () => ({ supervisorOrganizeTimeoutSeconds: 45,
|
||||
supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject: false, experienceMinEvents: 200 }) }), async () => runtime, pool)
|
||||
const result = await service.run(request)
|
||||
assert.equal(result.status, 'completed')
|
||||
assert.equal(result.coverage?.stories?.status, 'completed')
|
||||
const evidence = db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence)
|
||||
assert.equal(evidence.map(item => item.content).join(''), content)
|
||||
const before = calls
|
||||
assert.equal((await service.run(request)).status, 'no_change')
|
||||
assert.equal(calls, before)
|
||||
report.messages = 1
|
||||
report.characters = content.length
|
||||
report.status = result.status
|
||||
report.storyCalls = result.coverage?.stories?.calls
|
||||
report.noChangeAdditionalCalls = calls - before
|
||||
}
|
||||
assert.deepEqual(sql.prepare('PRAGMA foreign_key_check').all(), [])
|
||||
assert.equal(sql.prepare('PRAGMA integrity_check').get()!.integrity_check, 'ok')
|
||||
report.integrity = 'ok'
|
||||
report.passed = true
|
||||
} catch (error) {
|
||||
report.passed = false
|
||||
report.errorType = error instanceof Error ? error.name : 'Error'
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
report.externalCalls = calls
|
||||
report.elapsedMs = performance.now() - started
|
||||
db.close()
|
||||
pool.dispose()
|
||||
report.databaseBytes = (await stat(path)).size
|
||||
await writeFile(join(directory, 'metrics.json'), JSON.stringify(report, null, 2))
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
}
|
||||
}
|
||||
void main().catch(() => { console.error('Synthetic supervision validation preflight failed'); process.exitCode = 1 })
|
||||
@@ -18,6 +18,7 @@ export class AgentAcpConnection {
|
||||
private closed = false
|
||||
private closeInput?: () => void
|
||||
private bridgeOrigin?: string
|
||||
private readonly routeLifetime = new AbortController()
|
||||
|
||||
constructor(
|
||||
process: RuntimeAcpProcessOwner,
|
||||
@@ -43,6 +44,7 @@ export class AgentAcpConnection {
|
||||
this.unsubscribeExit = process.subscribeExit?.(() => {
|
||||
if (this.closed) return
|
||||
const error = new Error('ACP Runtime process exited')
|
||||
this.routeLifetime.abort(error)
|
||||
controller?.error(error)
|
||||
controller = undefined
|
||||
rejectExit(error)
|
||||
@@ -89,17 +91,19 @@ export class AgentAcpConnection {
|
||||
socketPath?: string,
|
||||
imageToolName?: string
|
||||
): Promise<void> {
|
||||
this.routeLifetime.signal.throwIfAborted()
|
||||
if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready')
|
||||
const response = await fetch(`${this.bridgeOrigin}/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, imageToolName } : { release: true }) }),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
signal: AbortSignal.any([this.routeLifetime.signal, AbortSignal.timeout(10_000)])
|
||||
})
|
||||
if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`)
|
||||
}
|
||||
dispose(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.routeLifetime.abort(new Error('ACP transport is closed'))
|
||||
this.sessions.clear()
|
||||
this.unsubscribeOutput()
|
||||
this.unsubscribeExit()
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RuntimeAcpProcessOwner
|
||||
} from './runtime-acp-backend'
|
||||
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import { SemanticPromptStore } from './semantic-prompt-store'
|
||||
|
||||
const temporary: string[] = []
|
||||
@@ -23,6 +24,62 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentOwnedAcpPrompt', () => {
|
||||
it.each(['dispose', 'exit'])('aborts pending route HTTP requests on transport %s', async action => {
|
||||
const process = new MemoryProcess()
|
||||
let client!: Client
|
||||
const transport = new AgentAcpConnection(process, undefined, factory => {
|
||||
client = factory()
|
||||
return {} as ClientSideConnection
|
||||
})
|
||||
const signals: AbortSignal[] = []
|
||||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => new Promise((_resolve, reject) => {
|
||||
const signal = init!.signal!
|
||||
signals.push(signal)
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
try {
|
||||
await client.extNotification!('goodbuddy/modelBridgeReady', { origin: `http://127.0.0.1:1234/${'a'.repeat(43)}` })
|
||||
const install = transport.setModelRoute('session-1', 'operation-1', '/broker').catch(error => error)
|
||||
const release = transport.setModelRoute('session-2', 'operation-2').catch(error => error)
|
||||
expect(signals.every(signal => !signal.aborted)).toBe(true)
|
||||
if (action === 'dispose') transport.dispose()
|
||||
else await process.emitExit()
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true)
|
||||
expect(await install).toBeInstanceOf(Error)
|
||||
expect(await release).toBeInstanceOf(Error)
|
||||
await expect(transport.setModelRoute('session-1', 'operation-1')).rejects.toThrow()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
} finally { transport.dispose(); vi.unstubAllGlobals() }
|
||||
})
|
||||
|
||||
it('coalesces identical starts and rejects conflicting starts while initialization is pending', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-start-'))
|
||||
temporary.push(root)
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
transcript.prepare({ bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1',
|
||||
controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
|
||||
let initialize!: (value: { protocolVersion: number }) => void
|
||||
let finish!: (value: PromptResponse) => void
|
||||
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
|
||||
const prompt = vi.fn(() => new Promise<PromptResponse>(resolve => { finish = resolve }))
|
||||
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding-1', controllerId: 'controller-1',
|
||||
workspaceDirectory: '/workspace', process: new MemoryProcess(), transcript, completePrompt: async () => undefined,
|
||||
createConnection: () => ({ initialize: () => new Promise(resolve => { initialize = resolve }), newSession, prompt }) as unknown as ClientSideConnection })
|
||||
const request = { bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text' as const, text: 'test' }] }
|
||||
try {
|
||||
const first = owner.start(request)
|
||||
const replay = owner.start(request)
|
||||
await expect(owner.start({ ...request, prompt: [{ type: 'text', text: 'different' }] })).rejects.toMatchObject({ code: 'conflict' })
|
||||
initialize({ protocolVersion: 1 })
|
||||
expect(await first).toEqual(await replay)
|
||||
expect(newSession).toHaveBeenCalledOnce()
|
||||
expect(prompt).toHaveBeenCalledOnce()
|
||||
finish({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => expect(transcript.attach('binding-1', 'operation-1', 'controller-1').state).toBe('completed'))
|
||||
} finally { owner.close(); transcript.close() }
|
||||
})
|
||||
|
||||
it('refreshes prompt tools on the retained session and authorizes execution', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-'))
|
||||
temporary.push(root)
|
||||
@@ -61,7 +118,7 @@ describe('AgentOwnedAcpPrompt', () => {
|
||||
bindingId: 'binding-1', operationId, requestId: operationId,
|
||||
prompt: [{ type: 'text', text: 'Continue' }]
|
||||
})
|
||||
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId)
|
||||
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, expect.any(AbortSignal))
|
||||
if (index !== 2) {
|
||||
expect(index === 0 ? newSession : resumeSession).toHaveBeenLastCalledWith(expect.objectContaining({ mcpServers: mcpServers() }))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
} from '../shared/remote-agent-contracts'
|
||||
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import { raceWithAbort } from './async-utils'
|
||||
import {
|
||||
SemanticPromptStore,
|
||||
SemanticPromptStoreError
|
||||
@@ -31,7 +32,7 @@ export type AgentOwnedAcpPromptOptions = {
|
||||
expectedModel?: string
|
||||
process: RuntimeAcpProcessOwner
|
||||
transport?: AgentAcpConnection
|
||||
prepareSession?: (sessionId: string, operationId: string) => Promise<void>
|
||||
prepareSession?: (sessionId: string, operationId: string, signal: AbortSignal) => Promise<void>
|
||||
transcript: SemanticPromptStore
|
||||
completePrompt: (
|
||||
operationId: string,
|
||||
@@ -63,6 +64,11 @@ export class AgentOwnedAcpPrompt {
|
||||
#promptSettled?: Promise<void>
|
||||
#closed = false
|
||||
#initialized = false
|
||||
#starting?: {
|
||||
digest: string
|
||||
controller: AbortController
|
||||
promise: Promise<RemoteOwnedPromptStartResult>
|
||||
}
|
||||
#hasMcpServers = false
|
||||
readonly #questions = new Map<string, {
|
||||
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
|
||||
@@ -86,6 +92,7 @@ export class AgentOwnedAcpPrompt {
|
||||
async start(
|
||||
request: RemoteOwnedPromptStartRequest
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
if (this.#closed) throw new Error('ACP Session is closed')
|
||||
if (
|
||||
request.bindingId !== this.#options.bindingId
|
||||
) {
|
||||
@@ -95,6 +102,12 @@ export class AgentOwnedAcpPrompt {
|
||||
)
|
||||
}
|
||||
const startDigest = digest(canonicalJson(request))
|
||||
if (this.#starting) {
|
||||
if (this.#starting.digest !== startDigest) {
|
||||
throw new SemanticPromptStoreError('Another ACP prompt is starting', 'conflict')
|
||||
}
|
||||
return this.#starting.promise
|
||||
}
|
||||
const existing = this.#options.transcript.findStarted({
|
||||
bindingId: request.bindingId,
|
||||
operationId: request.operationId,
|
||||
@@ -111,9 +124,39 @@ export class AgentOwnedAcpPrompt {
|
||||
)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const promise = this.#start(request, startDigest, controller.signal)
|
||||
this.#starting = { digest: startDigest, controller, promise }
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
this.#starting = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async #start(
|
||||
request: RemoteOwnedPromptStartRequest,
|
||||
startDigest: string,
|
||||
signal: AbortSignal
|
||||
): Promise<RemoteOwnedPromptStartResult> {
|
||||
const whileStarting = async <T>(operation: Promise<T>): Promise<T> => {
|
||||
const result = await raceWithAbort(this.#whileProcessAlive(operation), signal)
|
||||
signal.throwIfAborted()
|
||||
return result
|
||||
}
|
||||
const retainSession = async (operation: Promise<string>): Promise<string> =>
|
||||
whileStarting(operation.then(sessionId => {
|
||||
if (signal.aborted) {
|
||||
// A native session can be created after its owner was cancelled.
|
||||
void this.#connection.closeSession({ sessionId }).catch(() => undefined)
|
||||
} else {
|
||||
this.#sessionId = sessionId
|
||||
}
|
||||
return sessionId
|
||||
}))
|
||||
const mcpServers = this.#options.mcpServers?.() ?? []
|
||||
if (!this.#initialized) {
|
||||
const initialization = await this.#whileProcessAlive(
|
||||
const initialization = await whileStarting(
|
||||
this.#transport.initialize({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
@@ -134,38 +177,36 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
if (request.acpSessionId !== undefined && this.#sessionId === undefined) {
|
||||
if (initialization.agentCapabilities?.loadSession === true) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#sessionId = await retainSession(
|
||||
this.#connection.loadSession({
|
||||
sessionId: request.acpSessionId,
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
})
|
||||
}).then(() => request.acpSessionId!)
|
||||
)
|
||||
} else if (
|
||||
initialization.agentCapabilities?.sessionCapabilities?.resume != null
|
||||
) {
|
||||
await this.#whileProcessAlive(
|
||||
this.#sessionId = await retainSession(
|
||||
this.#connection.resumeSession({
|
||||
sessionId: request.acpSessionId,
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
})
|
||||
}).then(() => request.acpSessionId!)
|
||||
)
|
||||
} else {
|
||||
throw new Error('ACP Runtime cannot resume the requested session')
|
||||
}
|
||||
this.#sessionId = request.acpSessionId
|
||||
} else if (this.#sessionId === undefined) {
|
||||
const created = await this.#whileProcessAlive(
|
||||
this.#sessionId = await retainSession(
|
||||
this.#connection.newSession({
|
||||
cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
})
|
||||
}).then(created => created.sessionId)
|
||||
)
|
||||
this.#sessionId = created.sessionId
|
||||
}
|
||||
if (this.#options.expectedModel !== undefined) {
|
||||
const configured = await this.#whileProcessAlive(
|
||||
const configured = await whileStarting(
|
||||
this.#connection.setSessionConfigOption({
|
||||
sessionId: this.#sessionId!,
|
||||
configId: 'model',
|
||||
@@ -190,13 +231,14 @@ export class AgentOwnedAcpPrompt {
|
||||
) {
|
||||
throw new Error('ACP session identity cannot change within a binding')
|
||||
} else if (mcpServers.length > 0 || this.#hasMcpServers) {
|
||||
await this.#whileProcessAlive(this.#connection.resumeSession({
|
||||
await whileStarting(this.#connection.resumeSession({
|
||||
sessionId: this.#sessionId!, cwd: this.#options.workspaceDirectory,
|
||||
mcpServers
|
||||
}))
|
||||
}
|
||||
this.#hasMcpServers = mcpServers.length > 0
|
||||
await this.#options.prepareSession?.(this.#sessionId!, request.operationId)
|
||||
await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId, signal)))
|
||||
signal.throwIfAborted()
|
||||
this.#active = {
|
||||
operationId: request.operationId
|
||||
}
|
||||
@@ -251,6 +293,7 @@ export class AgentOwnedAcpPrompt {
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.#starting?.controller.abort(new Error('ACP prompt startup cancelled'))
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
if (this.#sessionId !== undefined && !this.#closed) {
|
||||
@@ -396,6 +439,7 @@ export class AgentOwnedAcpPrompt {
|
||||
return
|
||||
}
|
||||
this.#closed = true
|
||||
this.#starting?.controller.abort(new Error('ACP Session is closed'))
|
||||
this.#acceptQuestions = false
|
||||
this.#questions.clear()
|
||||
this.#unregisterSession?.()
|
||||
|
||||
@@ -44,6 +44,30 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentProtocolServer connection bounds', () => {
|
||||
it('dispatches cancellation and peer requests before a native startup response', async () => {
|
||||
const gate = deferred<void>()
|
||||
const calls: string[] = []
|
||||
const harness = createHarness({ methods: {
|
||||
'runtime/startPrompt': async () => { calls.push('start'); await gate.promise; return { started: true } },
|
||||
'runtime/escalateCancellation': async () => { calls.push('cancel'); return { stopped: true } },
|
||||
'runtime/getAcpCursors': async () => { calls.push('peer'); return {} }
|
||||
} })
|
||||
try {
|
||||
harness.socket.receive(Buffer.concat([
|
||||
controlRequest(harness.controller, 'start', '1', 1, 'runtime/startPrompt'),
|
||||
controlRequest(harness.controller, 'cancel', '1', 1, 'runtime/escalateCancellation'),
|
||||
controlRequest(harness.controller, 'peer', '1', 1, 'runtime/getAcpCursors')
|
||||
].map(Buffer.from)))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(calls).toEqual(['start', 'cancel', 'peer'])
|
||||
expect(harness.socket.writes).toHaveLength(4)
|
||||
gate.resolve()
|
||||
await waitFor(() => harness.socket.writes.length === 6)
|
||||
expect(harness.socket.maximumConcurrentWrites).toBe(1)
|
||||
expect(harness.socket.destroyCalled).toBe(false)
|
||||
} finally { gate.resolve(); harness.close() }
|
||||
})
|
||||
|
||||
it('accepts a frame fragmented into single-byte socket chunks', async () => {
|
||||
const harness = createHarness()
|
||||
const frame = controlRequest(
|
||||
|
||||
@@ -866,11 +866,19 @@ class ProtocolConnection {
|
||||
if (!('id' in request)) {
|
||||
await this.#invokeNotification(request, channel)
|
||||
} else {
|
||||
await this.#invokeRequest(request, channel)
|
||||
await this.#sendChannelClose(channel)
|
||||
this.#channels.close(channel.channelId)
|
||||
this.#channelKinds.delete(channel.channelId)
|
||||
return
|
||||
const completion = this.#invokeRequest(request, channel).then(async () => {
|
||||
if (this.#closed) return
|
||||
await this.#sendChannelClose(channel)
|
||||
this.#channels.close(channel.channelId)
|
||||
this.#channelKinds.delete(channel.channelId)
|
||||
})
|
||||
if (request.method === 'runtime/startPrompt') {
|
||||
// Startup holds its admitted request channel (and its existing capacity
|
||||
// slot), but must leave the socket free for cancellation and peer work.
|
||||
void completion.catch(error => this.#close(protocolFailureCategory('dispatch', error)))
|
||||
} else {
|
||||
await completion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -923,6 +931,7 @@ class ProtocolConnection {
|
||||
controllerTakeoverProven: this.#takeoverProven,
|
||||
signal: this.#abortController.signal
|
||||
})
|
||||
if (this.#closed) return
|
||||
if (request.method === 'runtime/resumeAcpChannel') {
|
||||
this.#bindResumedAcpChannel(result)
|
||||
}
|
||||
@@ -941,6 +950,7 @@ class ProtocolConnection {
|
||||
result: result ?? null
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.#closed) return
|
||||
const serviceCode = typedErrorCode(error)
|
||||
await this.#sendJson(channel, {
|
||||
jsonrpc: '2.0',
|
||||
|
||||
@@ -518,6 +518,170 @@ function harness(input: {
|
||||
}
|
||||
|
||||
describe('RuntimeAcpBackend', () => {
|
||||
it.each(['cancel', 'deadline'])('does not hold peer control while releasing a startup route after %s', async reason => {
|
||||
const fixture = await ownedHarness(true)
|
||||
const releaseRoute = deferred()
|
||||
let finishModel: (() => Promise<void>) | undefined
|
||||
let releasing = false
|
||||
try {
|
||||
await fixture.start()
|
||||
const process = fixture.processes[0]!
|
||||
const originalWrite = process.onWrite!
|
||||
process.onWrite = payload => {
|
||||
const request = JSON.parse(Buffer.from(payload).toString())
|
||||
const reply = (result: unknown) => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`)
|
||||
if (request.method === 'session/new') void reply({ sessionId: 'starting-session' })
|
||||
else if (request.method === 'session/set_config_option' && request.params.sessionId === 'starting-session') {
|
||||
finishModel = () => reply({ configOptions: [{ id: 'model', name: 'Model', type: 'select', currentValue: request.params.value, options: [] }] })
|
||||
} else originalWrite(payload)
|
||||
}
|
||||
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
|
||||
vi.useFakeTimers()
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
|
||||
operationId: 'starting-request', requestId: 'starting-request',
|
||||
deadlineAt: reason === 'deadline' ? new Date(1100).toISOString() : UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
|
||||
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }).catch(error => error)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(finishModel).toBeDefined()
|
||||
vi.mocked(AgentAcpConnection.prototype.setModelRoute).mockImplementation(async (_session, _operation, socket) => {
|
||||
if (!socket) { releasing = true; await releaseRoute.promise }
|
||||
})
|
||||
let cancelled = reason === 'deadline', peer = false
|
||||
const cancellation = reason === 'cancel' ? invoke(fixture, 'runtime/escalateCancellation', {
|
||||
bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'starting-session', reason: 'requested'
|
||||
}).then(() => { cancelled = true }) : undefined
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(releasing).toBe(true)
|
||||
const query = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peer = true })
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
const observed = { cancelled, peer }
|
||||
releaseRoute.resolve()
|
||||
await finishModel!()
|
||||
finishModel = undefined
|
||||
await Promise.all([starting, cancellation, query])
|
||||
expect(observed).toEqual({ cancelled: true, peer: true })
|
||||
expect(process.stops).toHaveLength(0)
|
||||
expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running')
|
||||
expect(fixture.page().events.filter(event => event.kind === 'prompt-terminal')).toHaveLength(0)
|
||||
} finally {
|
||||
releaseRoute.resolve(); await finishModel?.(); await fixture.prompts[0]?.()
|
||||
vi.useRealTimers(); await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('removes a late startup route after cancellation without revoking its peer route', async () => {
|
||||
const fixture = await ownedHarness(true)
|
||||
const install = deferred()
|
||||
const entered = deferred()
|
||||
const routes = new Map<string, string>([['session-1', 'request-1']])
|
||||
try {
|
||||
await fixture.start()
|
||||
const process = fixture.processes[0]!
|
||||
const originalWrite = process.onWrite!
|
||||
process.onWrite = payload => {
|
||||
const request = JSON.parse(Buffer.from(payload).toString())
|
||||
if (request.method === 'session/new') void process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'starting-session' } })}\n`)
|
||||
else originalWrite(payload)
|
||||
}
|
||||
vi.mocked(AgentAcpConnection.prototype.setModelRoute).mockImplementation(async (session, operation, socket) => {
|
||||
if (socket) { entered.resolve(); await install.promise; routes.set(session, operation) }
|
||||
else if (routes.get(session) === operation) routes.delete(session)
|
||||
})
|
||||
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
|
||||
operationId: 'starting-request', requestId: 'starting-request', deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
|
||||
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }).catch(error => error)
|
||||
await entered.promise
|
||||
await invoke(fixture, 'runtime/escalateCancellation', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'starting-session', reason: 'requested' })
|
||||
expect(await starting).toBeInstanceOf(Error)
|
||||
install.resolve()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect([...routes]).toEqual([['session-1', 'request-1']])
|
||||
expect(process.stops).toHaveLength(0)
|
||||
const page = fixture.transcript.page({ bindingId: 'starting', operationId: 'starting-request', controllerId: fixture.context.controller.controllerId, afterSequence: '0', limit: 10 })
|
||||
expect(page.state).toBe('cancelled')
|
||||
expect(page.events.filter(event => event.kind === 'prompt-terminal')).toHaveLength(1)
|
||||
} finally { install.resolve(); await fixture.prompts[0]?.(); await fixture.cleanup() }
|
||||
})
|
||||
|
||||
it.each(['watchdog', 'dispose'])('interrupts stalled native initialization through %s', async action => {
|
||||
vi.useFakeTimers()
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
|
||||
try {
|
||||
await open(fixture)
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
|
||||
let settled = false
|
||||
const starting = invoke(fixture, 'runtime/startPrompt', {
|
||||
bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1', prompt: [{ type: 'text', text: 'test' }]
|
||||
}).catch(() => undefined).finally(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fixture.process.writes).toHaveLength(1)
|
||||
let disposalError: unknown
|
||||
const disposal = action === 'dispose' ? fixture.backend.dispose().catch((error: unknown) => { disposalError = error }) : undefined
|
||||
await vi.advanceTimersByTimeAsync(action === 'watchdog' ? 121000 : 10000)
|
||||
const observed = { settled, stops: fixture.process.stops.length, disposalError }
|
||||
const initialize = JSON.parse(Buffer.from(fixture.process.writes[0]!).toString())
|
||||
await fixture.process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: initialize.id, error: { code: -32603, message: 'release fixture' } })}\n`)
|
||||
await Promise.all([starting, disposal])
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(observed).toEqual({ settled: true, stops: 1, disposalError: undefined })
|
||||
expect(fixture.process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()).method)).toEqual(['initialize'])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
await fixture.backend.dispose().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['cancel', 'deadline'])('keeps a shared peer responsive during native startup and %s', async reason => {
|
||||
const fixture = await ownedHarness(true)
|
||||
let releaseStartup: (() => Promise<void>) | undefined
|
||||
try {
|
||||
await fixture.start()
|
||||
const process = fixture.processes[0]!
|
||||
const originalWrite = process.onWrite!
|
||||
process.onWrite = payload => {
|
||||
const request = JSON.parse(Buffer.from(payload).toString())
|
||||
if (request.method === 'session/new') {
|
||||
releaseStartup = () => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'late-session' } })}\n`)
|
||||
} else originalWrite(payload)
|
||||
}
|
||||
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
|
||||
vi.useFakeTimers()
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
|
||||
operationId: 'starting-request', requestId: 'starting-request',
|
||||
deadlineAt: reason === 'deadline' ? new Date(1100).toISOString() : UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
|
||||
let startSettled = false
|
||||
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] })
|
||||
.catch(() => undefined).finally(() => { startSettled = true })
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(releaseStartup).toBeDefined()
|
||||
let peerSettled = false
|
||||
const peer = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peerSettled = true })
|
||||
let cancelled: Promise<unknown> | undefined
|
||||
if (reason === 'cancel') cancelled = invoke(fixture, 'runtime/escalateCancellation', {
|
||||
bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'opening', reason: 'requested'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
const observed = { peerSettled, startSettled, stops: process.stops.length }
|
||||
// Release the fake native response even on the old implementation.
|
||||
await releaseStartup!()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await Promise.all([starting, peer, cancelled])
|
||||
expect(observed).toEqual({ peerSettled: true, startSettled: true, stops: 0 })
|
||||
expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running')
|
||||
const writes = process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()))
|
||||
expect(writes.filter(request => request.method === 'session/prompt')).toHaveLength(1)
|
||||
expect(writes).toContainEqual(expect.objectContaining({ method: 'session/close', params: { sessionId: 'late-session' } }))
|
||||
await fixture.prompts[0]!()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
} finally {
|
||||
await releaseStartup?.()
|
||||
await fixture.prompts[0]?.()
|
||||
vi.useRealTimers()
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('commits completion before idle cleanup and preserves it on cleanup failure (shared=%s)', async shared => {
|
||||
for (const failure of ['stop', 'reconcile'] as const) {
|
||||
const fixture = await ownedHarness(shared)
|
||||
|
||||
@@ -440,7 +440,8 @@ export class RuntimeAcpBackend {
|
||||
'runtime/preparePrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#prepare(params, context)),
|
||||
'runtime/startPrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
|
||||
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context))
|
||||
.then(({ started }) => started),
|
||||
'runtime/attachPrompt': (params, context) =>
|
||||
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
|
||||
'runtime/respondToQuestion': (params, context) =>
|
||||
@@ -1441,7 +1442,7 @@ export class RuntimeAcpBackend {
|
||||
return completion
|
||||
}
|
||||
|
||||
async #startOwnedPrompt(
|
||||
#startOwnedPrompt(
|
||||
params: unknown,
|
||||
context: ProtocolMethodContext
|
||||
) {
|
||||
@@ -1464,7 +1465,7 @@ export class RuntimeAcpBackend {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
return existing
|
||||
return { started: Promise.resolve(existing) }
|
||||
}
|
||||
if (
|
||||
this.#options.semanticPrompts === undefined ||
|
||||
@@ -1501,11 +1502,17 @@ export class RuntimeAcpBackend {
|
||||
process: binding.process,
|
||||
...(binding.sharedProcess ? {
|
||||
transport: binding.sharedProcess.transport,
|
||||
prepareSession: async (sessionId: string, operationId: string) => {
|
||||
await binding.sharedProcess!.transport.setModelRoute(
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath,
|
||||
binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined
|
||||
)
|
||||
prepareSession: async (sessionId: string, operationId: string, signal: AbortSignal) => {
|
||||
const transport = binding.sharedProcess!.transport
|
||||
try {
|
||||
await transport.setModelRoute(
|
||||
sessionId, operationId, binding.modelBridgeBroker!.socketPath,
|
||||
binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined
|
||||
)
|
||||
} finally {
|
||||
// Cancellation can release the route before this installation finishes.
|
||||
if (signal.aborted) await transport.setModelRoute(sessionId, operationId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
} : {}),
|
||||
transcript: this.#options.semanticPrompts,
|
||||
@@ -1523,13 +1530,20 @@ export class RuntimeAcpBackend {
|
||||
binding.poisoned ? 'outcome-unknown' : status
|
||||
})
|
||||
}
|
||||
const result = await binding.ownedAcp.start(request)
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
return result
|
||||
const owner = binding.ownedAcp
|
||||
// Native initialization must not hold the global control queue: cancellation,
|
||||
// watchdogs and other bindings still need to make progress while it waits.
|
||||
const started = owner.start(request).then(result => {
|
||||
if (binding.ownedAcp === owner && binding.activeOperationId === request.operationId) {
|
||||
prepared.ownedPromptStarted = true
|
||||
if (binding.ownedPromptStartTimer !== undefined) {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
return { started }
|
||||
}
|
||||
|
||||
#attachOwnedPrompt(
|
||||
@@ -1889,7 +1903,9 @@ export class RuntimeAcpBackend {
|
||||
binding.modelBridgeClient = undefined
|
||||
let brokerError: unknown
|
||||
if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) {
|
||||
await binding.sharedProcess.transport.setModelRoute(
|
||||
// Broker shutdown below revokes dispatch authority. Native route cleanup
|
||||
// is bounded by the transport and must not hold the global control queue.
|
||||
void binding.sharedProcess.transport.setModelRoute(
|
||||
binding.ownedAcp.sessionId, binding.activeOperationId
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -3168,9 +3168,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
} catch {
|
||||
detail = undefined
|
||||
}
|
||||
throw new Error(
|
||||
throw Object.assign(new Error(
|
||||
detail ?? `模型接口请求失败(HTTP ${response.status})`
|
||||
)
|
||||
), { status: response.status })
|
||||
}
|
||||
|
||||
let receivedStop = false
|
||||
@@ -3208,6 +3208,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
}
|
||||
return { answer, usage }
|
||||
} catch (error) {
|
||||
const usageEvent = createUsageEvent(request.requestId, anthropic ? 'anthropic' : 'openai', this.options.model, usage)
|
||||
if (usageEvent) yield usageEvent
|
||||
return normalizeRequestError(error, modelRequest.timedOut())
|
||||
} finally {
|
||||
modelRequest.clear()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import {
|
||||
@@ -263,6 +264,16 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function stalledFetch(request: Request): Promise<Response> {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
// Like a live fetch, retain the Request until cancellation. Keeping only its
|
||||
// signal lets Undici's weakly referenced controller disappear during GC.
|
||||
const abort = (): void => reject(request.signal.reason);
|
||||
if (request.signal.aborted) abort();
|
||||
else request.signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function embeddedRuntime(
|
||||
client: ReturnType<typeof createOpencodeClient>,
|
||||
overrides: Partial<ConstructorParameters<typeof OpenCodeRuntime>[0]> = {},
|
||||
@@ -3323,6 +3334,35 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a stalled SDK fetch until abort even under garbage collection", () => {
|
||||
const output = execFileSync(process.execPath, ["--expose-gc", "--input-type=module", "-e", `
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
import { setImmediate as turn } from 'node:timers/promises';
|
||||
const stalledFetch = ${stalledFetch.toString()};
|
||||
const source = new AbortController();
|
||||
let requestSignal, requestRef, result;
|
||||
const sdk = createOpencodeClient({ baseUrl: 'http://127.0.0.1:1', throwOnError: false,
|
||||
fetch: request => {
|
||||
requestSignal = request.signal;
|
||||
requestRef = new WeakRef(request);
|
||||
return stalledFetch(request);
|
||||
}
|
||||
});
|
||||
const operation = sdk.mcp.disconnect({ name: 'gc-fixture' }, { signal: source.signal });
|
||||
operation.then(value => { result = value; });
|
||||
while (!requestSignal) await turn();
|
||||
for (let i = 0; i < 10; i++) { await turn(); gc(); await turn(); }
|
||||
const requestRetained = Boolean(requestRef.deref());
|
||||
source.abort(new Error('controlled deadline'));
|
||||
await turn();
|
||||
console.log(JSON.stringify({ requestRetained, sourceAborted: source.signal.aborted,
|
||||
requestAborted: requestSignal.aborted, sdkSettledWithError: Boolean(result?.error) }));
|
||||
`], { encoding: "utf8", timeout: 4_000 });
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
requestRetained: true, sourceAborted: true, requestAborted: true, sdkSettledWithError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
|
||||
"handles installed SDK fetch results: %s", async (scenario) => {
|
||||
const setup = runClient([
|
||||
@@ -3344,7 +3384,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
order.push("disconnect");
|
||||
signals.push(request.signal);
|
||||
if (scenario === "cleanup-transport") throw failure;
|
||||
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
|
||||
if (scenario === "cleanup-timeout") return stalledFetch(request);
|
||||
return Response.json(scenario !== "cleanup-false");
|
||||
}
|
||||
order.push("add");
|
||||
@@ -4680,6 +4720,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it.each(["rejection", "error-result", "timeout"])("settles failed submission with silent SSE: %s", async (failure) => {
|
||||
vi.useFakeTimers();
|
||||
const setup = runClient([]);
|
||||
const controller = new AbortController();
|
||||
let readerClosed = false;
|
||||
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
|
||||
stream: (async function* () {
|
||||
try {
|
||||
yield* [];
|
||||
await new Promise<void>((resolve) => {
|
||||
if (options?.signal?.aborted) resolve();
|
||||
else options?.signal?.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
} finally { readerClosed = true; }
|
||||
})(),
|
||||
}) as never);
|
||||
vi.mocked(setup.session.promptAsync).mockImplementation(async () => {
|
||||
if (failure === "timeout") return await new Promise(() => {});
|
||||
if (failure === "error-result") return { error: { data: { message: "submission rejected" } } } as never;
|
||||
throw new Error("submission rejected");
|
||||
});
|
||||
const runtime = new OpenCodeRuntime(options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }), {
|
||||
createClient: () => setup.client, controlRequestTimeoutMs: 10,
|
||||
});
|
||||
let settled = false;
|
||||
let error: unknown;
|
||||
const pending = (async () => {
|
||||
for await (const event of runtime.run({ requestId: "silent", conversationId: "silent", prompt: "test" }, controller.signal)) { void event; }
|
||||
})().catch((failure: unknown) => { error = failure; }).finally(() => { settled = true; });
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(settled).toBe(true);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe(failure === "timeout" ? "OpenCode 提交请求超时" : "submission rejected");
|
||||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||||
expect(setup.session.abort).toHaveBeenCalledOnce();
|
||||
expect(readerClosed).toBe(true);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
} finally {
|
||||
controller.abort();
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
await pending;
|
||||
await runtime.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a rejected async prompt instead of reporting success", async () => {
|
||||
const { client, session } = runClient([
|
||||
{
|
||||
|
||||
@@ -2559,6 +2559,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const submittedChecklistCalls = new Set<string>();
|
||||
const pendingChecklistUpdates: RuntimeChecklist[] = [];
|
||||
let runFailed = false;
|
||||
let submissionFailure: { error: unknown } | undefined;
|
||||
try {
|
||||
const promptText = promptWithUntrustedConversationHistory(
|
||||
request,
|
||||
@@ -2643,7 +2644,15 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
),
|
||||
signal,
|
||||
);
|
||||
prompt.catch(() => undefined);
|
||||
void prompt.then((result) => {
|
||||
if (result.error) {
|
||||
throw new Error(opencodeErrorMessage(result.error, "OpenCode 提交请求失败"));
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
submissionFailure = { error };
|
||||
// Wake a silent SSE reader without cancelling any peer subscription.
|
||||
subscriptionController.abort(error);
|
||||
});
|
||||
|
||||
const repliedPermissionIds = new Set<string>();
|
||||
const ownedSessions = new Set([sessionId]);
|
||||
@@ -3174,7 +3183,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
throw submissionFailure?.error ?? error;
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abortSession);
|
||||
// Keep the conversation locked until its session-wide abort settles.
|
||||
|
||||
@@ -320,12 +320,13 @@ it('persists supervision knowledge locators and keeps memory sources distinct',
|
||||
}
|
||||
database.saveSupervisionResult(result)
|
||||
const graph = database.getSupervisionGraph()
|
||||
expect(graph.sources).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ source_type: 'knowledge', locator_json: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }),
|
||||
expect.objectContaining({ source_type: 'memory', locator_json: null })
|
||||
const sources = (graph.sources as Array<{ id: string }>).map(source => database.getSupervisionSource(source.id)!)
|
||||
expect(sources).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }),
|
||||
expect.objectContaining({ sourceType: 'memory', locatorJson: null })
|
||||
]))
|
||||
const source = (graph.sources as Array<{ id: string; source_type: string }>).find((item) => item.source_type === 'knowledge')!
|
||||
expect(database.getSupervisionSource(source.id)).toMatchObject({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) })
|
||||
const source = sources.find(item => item.sourceType === 'knowledge')!
|
||||
expect(source).toMatchObject({ content: result.evidence[0]!.content, sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) })
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -443,7 +444,8 @@ it.each(['same-project', 'different-project'] as const)('isolates supervision mo
|
||||
expect(graph.events).toHaveLength(1)
|
||||
expect(graph.entities).toHaveLength(2)
|
||||
expect(graph.sources).toHaveLength(1)
|
||||
expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId, locator_json: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) })]))
|
||||
expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId })]))
|
||||
expect(database.getSupervisionSource(sourceId)).toMatchObject({ locatorJson: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) })
|
||||
}
|
||||
expect(inspected.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
} finally { inspected.close() }
|
||||
@@ -2841,6 +2843,14 @@ describe('AssistantDatabase', () => {
|
||||
expect.objectContaining({
|
||||
kind: 'channel',
|
||||
channel: 'dingtalk'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Telegram',
|
||||
kind: 'channel',
|
||||
channel: 'telegram',
|
||||
rootPath: 'C:\\Users\\test',
|
||||
executionSpace: { kind: 'local', rootPath: 'C:\\Users\\test' },
|
||||
runtimeSelection: { provider: 'model' }
|
||||
})
|
||||
])
|
||||
expect(second.map((project) => project.id)).toEqual(
|
||||
@@ -2891,6 +2901,101 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('relaxes schema 60 channel checks without rewriting or losing existing records', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-migration-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const original = new AssistantDatabase(path)
|
||||
original.initialize('C:\\Workspace')
|
||||
const projects = original.ensureChannelProjects('C:\\Channels')
|
||||
for (const project of projects.filter(project => project.channel !== 'telegram')) {
|
||||
const conversation = original.getOrCreateRemoteConversation({
|
||||
projectId: project.id, channel: project.channel!, accountId: 'legacy-account',
|
||||
externalConversationId: 'legacy-chat', conversationType: 'direct',
|
||||
title: 'Legacy conversation', accountDisplay: 'Legacy account',
|
||||
runtimeSelection: { provider: 'model' }
|
||||
})
|
||||
original.appendRemoteConversationMessage({ conversationId: conversation.id, role: 'user', content: 'Preserve this message' })
|
||||
}
|
||||
original.close()
|
||||
const legacy = new DatabaseSync(path)
|
||||
legacy.enableDefensive(false)
|
||||
legacy.exec(`PRAGMA foreign_keys = ON; DELETE FROM projects WHERE channel = 'telegram';
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_schema SET sql = replace(sql,
|
||||
'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
|
||||
'channel IN (''weixin'', ''wecom'', ''dingtalk'')')
|
||||
WHERE type = 'table' AND name IN ('projects', 'conversations');
|
||||
PRAGMA writable_schema = RESET; PRAGMA user_version = 60;`)
|
||||
const tables = ['projects', 'project_execution_spaces', 'conversations', 'messages']
|
||||
const rows = tables.map(table => legacy.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())
|
||||
const schema = legacy.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()
|
||||
expect(() => legacy.prepare("UPDATE projects SET channel = 'telegram' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
|
||||
legacy.close()
|
||||
|
||||
const migrated = new AssistantDatabase(path)
|
||||
const progress = vi.fn()
|
||||
migrated.upgradeStorage(progress, () => false, { migrateNotes: false, reclaimSpace: false })
|
||||
expect(progress).not.toHaveBeenCalled()
|
||||
const check = new DatabaseSync(path)
|
||||
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(tables.map(table => check.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())).toEqual(rows)
|
||||
expect(check.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()).toEqual(
|
||||
schema.map(row => ['projects', 'conversations'].includes(row.name as string) && row.type === 'table'
|
||||
? { ...row, sql: (row.sql as string).replace("channel IN ('weixin', 'wecom', 'dingtalk')", "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')") }
|
||||
: row)
|
||||
)
|
||||
expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
|
||||
expect(check.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
expect(() => check.prepare("UPDATE projects SET channel = 'invalid' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
|
||||
expect(() => check.prepare("UPDATE conversations SET channel = 'invalid' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
|
||||
check.close()
|
||||
|
||||
migrated.initialize('C:\\Workspace')
|
||||
const telegram = migrated.ensureChannelProjects('C:\\Telegram').find(project => project.channel === 'telegram')!
|
||||
const input = {
|
||||
projectId: telegram.id, channel: 'telegram' as const, accountId: 'bot',
|
||||
externalConversationId: '-100123', conversationType: 'group' as const,
|
||||
title: 'Telegram chat', accountDisplay: 'Telegram', runtimeSelection: { provider: 'model' as const }
|
||||
}
|
||||
const conversation = migrated.getOrCreateRemoteConversation(input)
|
||||
migrated.close()
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
expect(reopened.ensureChannelProjects('C:\\Ignored').find(project => project.channel === 'telegram')).toEqual(telegram)
|
||||
expect(reopened.getOrCreateRemoteConversation(input).id).toBe(conversation.id)
|
||||
expect(reopened.getConversation(conversation.id).remote?.channel).toBe('telegram')
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('rolls back the channel CHECK migration if a schema definition is unexpected', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-rollback-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize('C:\\Workspace')
|
||||
database.close()
|
||||
const legacy = new DatabaseSync(path)
|
||||
legacy.enableDefensive(false)
|
||||
legacy.exec(`PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_schema SET sql = replace(sql,
|
||||
'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
|
||||
'channel IN (''weixin'', ''wecom'', ''dingtalk'')') WHERE name = 'projects';
|
||||
UPDATE sqlite_schema SET sql = replace(sql,
|
||||
'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
|
||||
'channel IN (''weixin'')') WHERE name = 'conversations';
|
||||
PRAGMA writable_schema = RESET; PRAGMA user_version = 60;`)
|
||||
const before = legacy.prepare('SELECT * FROM sqlite_schema ORDER BY name').all()
|
||||
legacy.close()
|
||||
expect(() => database.upgradeStorage(() => undefined, () => false, { migrateNotes: false, reclaimSpace: false }))
|
||||
.toThrow('Unrecognized channel constraint: conversations')
|
||||
const check = new DatabaseSync(path)
|
||||
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
|
||||
expect(check.prepare('SELECT * FROM sqlite_schema ORDER BY name').all()).toEqual(before)
|
||||
expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
|
||||
check.close()
|
||||
})
|
||||
|
||||
it('persists one protected remote conversation per channel identity', async () => {
|
||||
const database = await createDatabase()
|
||||
const project = database.ensureChannelProjects(
|
||||
@@ -3099,6 +3204,31 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists an immediate terminal result without attachments or repeated attempt counts', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-channel-terminal-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize('C:\\Workspace')
|
||||
const entry = database.enqueueChannelResult({
|
||||
channel: 'telegram', eventId: 'permanent-failure', conversationId: 'chat',
|
||||
recipientId: 'user', status: 'completed', output: 'Keep result text',
|
||||
attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'eA==' }]
|
||||
})
|
||||
database.markChannelResult(entry.id, 'terminal')
|
||||
database.close()
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
reopened.markChannelResult(entry.id, 'terminal')
|
||||
reopened.markChannelResult(entry.id, 'failed')
|
||||
expect(reopened.listUndeliveredChannelResults('telegram')).toEqual([
|
||||
expect.objectContaining({ id: entry.id, state: 'terminal', attempts: 1,
|
||||
message: expect.objectContaining({ output: 'Keep result text' }) })
|
||||
])
|
||||
expect(reopened.listUndeliveredChannelResults('telegram')[0]!.message).not.toHaveProperty('attachments')
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('migrates exhausted legacy outbox failures to terminal state', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-channel-terminal-migration-')
|
||||
|
||||
@@ -136,7 +136,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 60
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62
|
||||
|
||||
function withoutHistoricalWorkMode(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
@@ -2623,6 +2623,11 @@ export class AssistantDatabase {
|
||||
channel: 'dingtalk',
|
||||
name: '钉钉',
|
||||
description: '钉钉远程消息与受控任务'
|
||||
},
|
||||
{
|
||||
channel: 'telegram',
|
||||
name: 'Telegram',
|
||||
description: 'Telegram 远程消息与受控任务'
|
||||
}
|
||||
]
|
||||
const find = database.prepare(
|
||||
@@ -3801,6 +3806,11 @@ export class AssistantDatabase {
|
||||
)`
|
||||
)
|
||||
.run(conversationId)
|
||||
// Older pending manifests grouped tasks without their conversation owner.
|
||||
database.prepare(`UPDATE supervision_review_sources SET conversation_id = ?
|
||||
WHERE source IN (SELECT 'task:' || id FROM tasks WHERE conversation_id = ?)
|
||||
AND run_id IN (SELECT id FROM supervision_runs WHERE status IN ('running', 'paused', 'failed'))`)
|
||||
.run(conversationId, conversationId)
|
||||
database
|
||||
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
|
||||
.run(conversationId)
|
||||
@@ -3809,6 +3819,12 @@ export class AssistantDatabase {
|
||||
'DELETE FROM conversations WHERE id = ? AND channel IS NULL'
|
||||
)
|
||||
.run(conversationId).changes === 1
|
||||
if (deleted) {
|
||||
const reviews = this.supervisionReviewStore()
|
||||
for (const row of database.prepare('SELECT DISTINCT run_id FROM supervision_review_sources WHERE conversation_id = ?').all(conversationId)) {
|
||||
reviews.omitDeletedConversations(String(row.run_id))
|
||||
}
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
if (deleted) this.executionTiming?.changed()
|
||||
return deleted
|
||||
@@ -4084,7 +4100,7 @@ export class AssistantDatabase {
|
||||
|
||||
markChannelResult(
|
||||
id: string,
|
||||
state: 'delivered' | 'failed'
|
||||
state: 'delivered' | 'failed' | 'terminal'
|
||||
): void {
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
@@ -4096,11 +4112,11 @@ export class AssistantDatabase {
|
||||
END,
|
||||
attempts = attempts + 1,
|
||||
message_json = CASE
|
||||
WHEN ? = 'delivered' OR attempts + 1 >= 5
|
||||
WHEN ? IN ('delivered', 'terminal') OR attempts + 1 >= 5
|
||||
THEN json_remove(message_json, '$.attachments')
|
||||
ELSE message_json
|
||||
END
|
||||
WHERE id = ?`
|
||||
WHERE id = ? AND state NOT IN ('delivered', 'terminal')`
|
||||
)
|
||||
.run(state, state, state, id)
|
||||
}
|
||||
@@ -4124,10 +4140,10 @@ export class AssistantDatabase {
|
||||
`WITH pending AS (
|
||||
SELECT id, message_json, state, attempts, created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY attempts ASC, created_at ASC
|
||||
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
|
||||
) AS position,
|
||||
SUM(LENGTH(CAST(message_json AS BLOB))) OVER (
|
||||
ORDER BY attempts ASC, created_at ASC
|
||||
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
|
||||
) AS cumulative_bytes
|
||||
FROM channel_outbox
|
||||
WHERE state != 'delivered'
|
||||
@@ -4136,7 +4152,7 @@ export class AssistantDatabase {
|
||||
SELECT id, message_json, state, attempts, created_at
|
||||
FROM pending
|
||||
WHERE position = 1 OR cumulative_bytes <= ?
|
||||
ORDER BY attempts ASC, created_at ASC
|
||||
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
@@ -9112,6 +9128,7 @@ export class AssistantDatabase {
|
||||
const timestamp = new Date().toISOString()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (result.coverage && result.runId) this.supervisionReviewStore().assertComplete(result.runId, result.coverage.omittedSources ?? 0)
|
||||
let story = database.prepare(
|
||||
'SELECT id FROM story_lines WHERE scope_json = ? LIMIT 1'
|
||||
).get(JSON.stringify(result.request.scope)) as { id: string } | undefined
|
||||
@@ -9139,6 +9156,7 @@ export class AssistantDatabase {
|
||||
resultId, runId, result.output.summary, result.output.changeDigest,
|
||||
JSON.stringify(result.output.openItems), JSON.stringify(result.coverage ?? { evidence: result.evidence.length }), timestamp
|
||||
)
|
||||
const graph: { entities: Record<string, unknown>[]; relations: Record<string, unknown>[] } = { entities: [], relations: [] }
|
||||
const saveFacts = (result: StoredSupervisionResult) => {
|
||||
// Model IDs are local to one batch, never persistent cross-run identities.
|
||||
const sourceIds = new Map(result.evidence.map((source) => [source.id, randomUUID()]))
|
||||
@@ -9217,15 +9235,8 @@ export class AssistantDatabase {
|
||||
story.id, persistentId(entityIds, relation.fromEntityId), persistentId(entityIds, relation.toEntityId), relation.relationType),
|
||||
source_reference_ids_json: sourceReferences(relation.sourceReferenceIds)
|
||||
}))
|
||||
database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object(
|
||||
'entities', json((SELECT json_group_array(json(value)) FROM (
|
||||
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities')
|
||||
UNION ALL SELECT value FROM json_each(?)
|
||||
))),
|
||||
'relations', json((SELECT json_group_array(json(value)) FROM (
|
||||
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations')
|
||||
UNION ALL SELECT value FROM json_each(?)
|
||||
)))) WHERE id = ?`).run(JSON.stringify(entities), JSON.stringify(relations), resultId)
|
||||
for (const entity of entities) graph.entities.push(entity)
|
||||
for (const relation of relations) graph.relations.push(relation)
|
||||
}
|
||||
if (result.coverage && result.runId) {
|
||||
for (let offset = 0;; offset += 10) {
|
||||
@@ -9237,12 +9248,15 @@ export class AssistantDatabase {
|
||||
// each leaf's full description and references remain independently readable.
|
||||
database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object(
|
||||
'entities', json((SELECT json_group_array(json(value)) FROM (
|
||||
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities') GROUP BY json_extract(value, '$.id')))),
|
||||
SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id')))),
|
||||
'relations', json((SELECT json_group_array(json(value)) FROM (
|
||||
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations') GROUP BY json_extract(value, '$.id')))))
|
||||
WHERE id = ?`).run(resultId)
|
||||
SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id')))))
|
||||
WHERE id = ?`).run(JSON.stringify(graph.entities), JSON.stringify(graph.relations), resultId)
|
||||
this.supervisionReviewStore().commitCheckpoints(result.runId, result.request)
|
||||
} else saveFacts(result)
|
||||
} else {
|
||||
saveFacts(result)
|
||||
database.prepare('UPDATE supervision_results SET graph_snapshot_json = ? WHERE id = ?').run(JSON.stringify(graph), resultId)
|
||||
}
|
||||
placeTimelineEvents(database, resultId)
|
||||
supersedeReextractedEvents(database, resultId)
|
||||
if (result.batch) this.saveReviewCheckpoints(result.batch)
|
||||
@@ -9268,6 +9282,36 @@ export class AssistantDatabase {
|
||||
return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined
|
||||
}
|
||||
|
||||
async listSupervisionResultsAsync(limit = 20, target?: SupervisionTarget, resultId?: string): Promise<ReturnType<AssistantDatabase['listSupervisionResults']>> {
|
||||
this.requireDatabase()
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('supervisionOverview', [limit, target, resultId])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.listSupervisionResults(limit, target, resultId)
|
||||
}
|
||||
|
||||
async getSupervisionGraphAsync(input: SupervisionGraphRequest = {}): Promise<Record<string, unknown>> {
|
||||
this.requireDatabase()
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('supervisionGraph', [input])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.getSupervisionGraph(input)
|
||||
}
|
||||
|
||||
getSupervisionStories(scope: SupervisionRunRequest['scope']) {
|
||||
const stories = this.supervisionStories()
|
||||
return { stories: stories.list(scope), experiences: this.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined),
|
||||
unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() }
|
||||
}
|
||||
|
||||
async getSupervisionStoriesAsync(scope: SupervisionRunRequest['scope']): Promise<ReturnType<AssistantDatabase['getSupervisionStories']>> {
|
||||
this.requireDatabase()
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('supervisionStories', [scope])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.getSupervisionStories(scope)
|
||||
}
|
||||
|
||||
listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) {
|
||||
const owner = target?.type === 'conversation' ? this.getConversation(target.conversationId)
|
||||
: target?.type === 'task' ? this.getTask(target.taskId) : undefined
|
||||
@@ -9320,7 +9364,7 @@ export class AssistantDatabase {
|
||||
attention: supervisionAttention(database, scope.kind === 'projects' ? scope.projectIds : undefined,
|
||||
new Date(range.from).toISOString(), new Date(range.to).toISOString()),
|
||||
events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId),
|
||||
sources: database.prepare('SELECT * FROM supervision_sources WHERE result_id = ?').all(input.resultId),
|
||||
sources: database.prepare('SELECT id, title, occurred_at FROM supervision_sources WHERE result_id = ? ORDER BY rowid').all(input.resultId),
|
||||
eventEntities: database.prepare(`SELECT ee.event_id, ee.entity_id FROM supervision_event_entities ee JOIN supervision_events e ON e.id = ee.event_id WHERE e.result_id = ?`).all(input.resultId),
|
||||
eventSources: database.prepare(`SELECT es.event_id, es.source_id FROM supervision_event_sources es JOIN supervision_events e ON e.id = es.event_id WHERE e.result_id = ?`).all(input.resultId)
|
||||
}
|
||||
@@ -9347,9 +9391,9 @@ export class AssistantDatabase {
|
||||
GROUP BY r.from_entity_id, r.to_entity_id, r.relation_type`).all(id),
|
||||
eventEntities: database.prepare('SELECT event_id, entity_id FROM supervision_event_entities WHERE event_id IN (SELECT id FROM temp.story_events)').all(),
|
||||
eventSources: database.prepare('SELECT event_id, source_id FROM supervision_event_sources WHERE event_id IN (SELECT id FROM temp.story_events)').all(),
|
||||
sources: database.prepare(`SELECT * FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources
|
||||
sources: database.prepare(`SELECT id, title, occurred_at FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources
|
||||
WHERE event_id IN (SELECT id FROM temp.story_events)) OR result_id IN (SELECT r.id FROM supervision_results r
|
||||
JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?)`).all(String((story as { scope_json: string }).scope_json)),
|
||||
JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?) ORDER BY rowid`).all(String((story as { scope_json: string }).scope_json)),
|
||||
attention: this.storyAttention(scope)
|
||||
}
|
||||
} finally { database.exec('DROP TABLE IF EXISTS temp.story_events') }
|
||||
@@ -10663,7 +10707,7 @@ export class AssistantDatabase {
|
||||
ADD COLUMN channel TEXT
|
||||
CHECK(
|
||||
channel IS NULL OR
|
||||
channel IN ('weixin', 'wecom', 'dingtalk')
|
||||
channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')
|
||||
);
|
||||
`)
|
||||
}
|
||||
@@ -10694,7 +10738,7 @@ export class AssistantDatabase {
|
||||
ALTER TABLE conversations ADD COLUMN channel TEXT
|
||||
CHECK(
|
||||
channel IS NULL OR
|
||||
channel IN ('weixin', 'wecom', 'dingtalk')
|
||||
channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')
|
||||
);
|
||||
`)
|
||||
}
|
||||
@@ -12299,6 +12343,47 @@ export class AssistantDatabase {
|
||||
`)
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 61) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
// SQLite's metadata-only CHECK relaxation preserves table rows, rowids,
|
||||
// indexes and references without rebuilding potentially large conversations.
|
||||
const previous = "channel IN ('weixin', 'wecom', 'dingtalk')"
|
||||
const expanded = "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')"
|
||||
const schemaVersion = database.prepare('PRAGMA schema_version').get() as { schema_version: number }
|
||||
database.enableDefensive(false)
|
||||
for (const table of ['projects', 'conversations']) {
|
||||
const definition = database.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table) as { sql: string }
|
||||
if (definition.sql.includes(expanded)) continue
|
||||
if (!definition.sql.includes(previous)) throw new Error(`Unrecognized channel constraint: ${table}`)
|
||||
database.exec('PRAGMA writable_schema = ON')
|
||||
database.prepare("UPDATE sqlite_schema SET sql = ? WHERE type = 'table' AND name = ?")
|
||||
.run(definition.sql.replace(previous, expanded), table)
|
||||
}
|
||||
database.exec(`PRAGMA schema_version = ${schemaVersion.schema_version + 1}; PRAGMA writable_schema = RESET;`)
|
||||
// Reparse with writable_schema disabled so invalid definitions roll back.
|
||||
database.prepare('SELECT channel FROM projects LIMIT 0').all()
|
||||
database.prepare('SELECT channel FROM conversations LIMIT 0').all()
|
||||
database.exec('PRAGMA user_version = 61; COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
} finally {
|
||||
database.exec('PRAGMA writable_schema = RESET')
|
||||
database.enableDefensive(true)
|
||||
}
|
||||
}
|
||||
if (version.user_version < 62) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS supervision_sources_result ON supervision_sources(result_id, occurred_at, source_type, id);
|
||||
CREATE INDEX IF NOT EXISTS supervision_results_created ON supervision_results(created_at);
|
||||
PRAGMA user_version = 62;
|
||||
COMMIT;
|
||||
`)
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
@@ -12343,11 +12428,11 @@ export class AssistantDatabase {
|
||||
return { summary: this.reviewSummary(request.scope, 'supervisor'), background: this.reviewBackground(request.scope) }
|
||||
}
|
||||
|
||||
async supervisionCandidates(request: SupervisionRunRequest): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
|
||||
async supervisionCandidates(request: SupervisionRunRequest, batch?: { projectId: string; crossProject: boolean }): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('reviewCandidates', [request])
|
||||
if (reader) return reader.call('reviewCandidates', [request, batch])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.listSupervisionCandidates(request)
|
||||
return this.listSupervisionCandidates(request, batch)
|
||||
}
|
||||
|
||||
supervisionSuggestions(): SupervisionSuggestionStore {
|
||||
|
||||
@@ -240,6 +240,18 @@ describe('persisted running-state duration', () => {
|
||||
expect(raw().prepare('PRAGMA index_info(messages_summary_idx)').all().map(row => row.name))
|
||||
.toEqual(['conversation_id', 'sequence', 'role', 'created_at'])
|
||||
expect(raw().prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
const replyId = task()
|
||||
database.startExecutionTiming(replyId)
|
||||
now += 7000
|
||||
database.updateTaskStatus(replyId, 'completed')
|
||||
database.endExecutionTiming(replyId)
|
||||
expect(stats()).toMatchObject({ durationMs: 7000, runningCount: 0, incomplete: true })
|
||||
database.close()
|
||||
now += 86_400_000
|
||||
database.initialize(directory)
|
||||
expect(stats()).toMatchObject({ durationMs: 7000, runningCount: 0, incomplete: true })
|
||||
expect(raw().prepare('SELECT duration_ms FROM execution_timing WHERE task_id = ?').get(replyId))
|
||||
.toEqual({ duration_ms: 7000 })
|
||||
})
|
||||
|
||||
it('deletes timing with its conversation and notifies snapshot subscribers', async () => {
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('subagent progress storage', () => {
|
||||
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']]) {
|
||||
expect(check.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name)).not.toContain(column)
|
||||
}
|
||||
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
|
||||
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
const readJson = (table: string, column: string, id: string) =>
|
||||
JSON.parse(check.prepare(`SELECT ${column} AS value FROM ${table} WHERE id = ?`).get(id)!.value as string)
|
||||
expect(readJson('conversations', 'context_state_json', header.id))
|
||||
@@ -164,7 +164,7 @@ describe('subagent progress storage', () => {
|
||||
sql.prepare('UPDATE task_events SET payload_json = ? WHERE id = ?').run('{"workMode":"ask"}', damagedId)
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
expect(sql.prepare('SELECT payload_json FROM task_events WHERE id = ?').get(damagedId)!.payload_json).toBe('{}')
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
|
||||
} finally { sql.close() }
|
||||
})
|
||||
|
||||
@@ -196,20 +196,32 @@ export class SupervisionExperienceStore {
|
||||
}
|
||||
|
||||
list(projectIds?: string[]): SupervisionExperience[] {
|
||||
const rows = this.db.prepare("SELECT * FROM supervision_experiences WHERE status = 'current' ORDER BY created_at, rowid").all()
|
||||
const links = this.db.prepare(`SELECT x.event_id, x.role, x.note, e.title, e.project_id, COALESCE(e.started_at, e.occurred_at) AS at,
|
||||
(SELECT s.id FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE es.event_id = e.id AND es.is_primary = 1) AS story_id,
|
||||
(SELECT s.name FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE es.event_id = e.id AND es.is_primary = 1) AS story_name
|
||||
const rows = this.db.prepare(`SELECT * FROM supervision_experiences WHERE status = 'current'
|
||||
AND id IN (SELECT x.experience_id FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id
|
||||
WHERE x.role = 'formed' AND e.superseded_by IS NULL)
|
||||
AND (? IS NULL OR id IN (SELECT x.experience_id FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id
|
||||
WHERE e.superseded_by IS NULL AND e.project_id != '' AND e.project_id IN (SELECT value FROM json_each(?))))
|
||||
ORDER BY created_at, rowid`).all(projectIds ? 1 : null, JSON.stringify(projectIds ?? []))
|
||||
if (!rows.length) return []
|
||||
// Scope selects experiences, not their links: cross-project context remains complete.
|
||||
const links = this.db.prepare(`SELECT x.experience_id, x.event_id, x.role, x.note, e.title, e.project_id,
|
||||
COALESCE(e.started_at, e.occurred_at) AS at, s.id AS story_id, s.name AS story_name
|
||||
FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id
|
||||
WHERE x.experience_id = ? AND e.superseded_by IS NULL ORDER BY at, e.id`)
|
||||
return rows.map(row => {
|
||||
const events = links.all(String(row.id)).map(link => ({ id: String(link.event_id), role: link.role as 'formed' | 'applied', note: String(link.note),
|
||||
LEFT JOIN supervision_event_stories es ON es.event_id = e.id AND es.is_primary = 1
|
||||
LEFT JOIN supervision_stories s ON s.id = es.story_id
|
||||
WHERE x.experience_id IN (SELECT value FROM json_each(?)) AND e.superseded_by IS NULL
|
||||
ORDER BY x.experience_id, at, e.id, x.role`).all(JSON.stringify(rows.map(row => row.id)))
|
||||
const byExperience = new Map<string, SupervisionExperience['events']>()
|
||||
for (const link of links) {
|
||||
const id = String(link.experience_id)
|
||||
const events = byExperience.get(id) ?? []
|
||||
events.push({ id: String(link.event_id), role: link.role as 'formed' | 'applied', note: String(link.note),
|
||||
title: String(link.title), projectId: link.project_id ? String(link.project_id) : null, at: String(link.at),
|
||||
storyId: link.story_id ? String(link.story_id) : null, storyName: link.story_name ? String(link.story_name) : null }))
|
||||
return { id: String(row.id), statement: String(row.statement), conditions: String(row.conditions), boundaries: String(row.boundaries),
|
||||
userEdited: row.user_edited === 1, events }
|
||||
// Experiences are global; a project view keeps those formed or applied in its projects.
|
||||
}).filter(item => item.events.some(event => event.role === 'formed') && (!projectIds || item.events.some(event => event.projectId && projectIds.includes(event.projectId))))
|
||||
storyId: link.story_id ? String(link.story_id) : null, storyName: link.story_name ? String(link.story_name) : null })
|
||||
byExperience.set(id, events)
|
||||
}
|
||||
return rows.map(row => ({ id: String(row.id), statement: String(row.statement), conditions: String(row.conditions), boundaries: String(row.boundaries),
|
||||
userEdited: row.user_edited === 1, events: byExperience.get(String(row.id)) ?? [] }))
|
||||
}
|
||||
|
||||
/** Snapshot of the experiences and links an adjustment changes, for undo. */
|
||||
|
||||
@@ -81,7 +81,7 @@ it('supervision preserves result history and protected identities within scope t
|
||||
expect(read(latest.id).entities.find((entity) => entity.id === confirmedEntity.id)).toMatchObject({ ...confirmedEntity, confirmation_state: 'confirmed' })
|
||||
expect(read(latest.id).relations[0]).toMatchObject({ id: relationId, reason: 'Original reason', confirmation_state: 'confirmed' })
|
||||
const storedGraph = db.getSupervisionGraph({ resultId: latest.id })
|
||||
const currentSource = (storedGraph.sources as Array<{ id: string; source_type: string }>).find((source) => source.source_type === 'conversation')!
|
||||
const currentSource = (storedGraph.sources as Array<{ id: string }>).find((source) => db.getSupervisionSource(source.id)!.sourceType === 'conversation')!
|
||||
expect((storedGraph.entities as Array<{ source_reference_ids_json: string }>).every((entity) => entity.source_reference_ids_json === JSON.stringify([currentSource.id]))).toBe(true)
|
||||
expect(read(a.id)).toEqual(original)
|
||||
db.applySupervisionRelationAction({ resultId: latest.id, relationId, action: 'revoke' })
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AgentRuntime } from '../agent/runtime'
|
||||
import { ModelAgentRuntime, noModelTools } from '../agent/model-runtime'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { SupervisionModelPool } from './supervision-model-pool'
|
||||
import { createProductionSuggestionPhraser } from './supervision-production'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function fixture(run: AgentRuntime['run'], timeoutSeconds = 30) {
|
||||
vi.useFakeTimers()
|
||||
const db = new AssistantDatabase(':memory:')
|
||||
db.initialize(process.cwd())
|
||||
const pool = new SupervisionModelPool()
|
||||
const release = vi.fn<NonNullable<AgentRuntime['releaseConversation']>>(async () => {})
|
||||
const dispose = vi.fn(async () => {})
|
||||
const runtime = { run: vi.fn(run), releaseConversation: release, dispose } as unknown as AgentRuntime
|
||||
const statuses = vi.spyOn(db, 'updateTaskStatus')
|
||||
const phrase = createProductionSuggestionPhraser(db, async () => ({ supervisorModelConcurrency: 1,
|
||||
supervisorOrganizeTimeoutSeconds: timeoutSeconds }), async () => runtime, pool)
|
||||
cleanups.push(() => { pool.dispose(); db.close() })
|
||||
return { db, pool, runtime, release, dispose, statuses,
|
||||
start: () => phrase({ systemInstruction: 'Suggest', outputContract: '{}', candidates: [] }) }
|
||||
}
|
||||
|
||||
it.each([
|
||||
Object.assign(new Error('Slow down'), { status: 429 }),
|
||||
Object.assign(new Error('Gateway unavailable'), { statusCode: 502 }),
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('socket closed'), { code: 'ECONNRESET' }) }),
|
||||
new TypeError('terminated'),
|
||||
'模型接口请求失败(HTTP 503)',
|
||||
'Gateway Timeout (HTTP 504)',
|
||||
'TypeError: terminated',
|
||||
'模型接口流式响应意外中断',
|
||||
'Rate limit exceeded'
|
||||
])('retries a transient failure once with fresh text, usage identities and cleanup: %s', async failure => {
|
||||
let calls = 0
|
||||
const finalized = vi.fn()
|
||||
const f = fixture(async function* (input, signal) {
|
||||
calls++
|
||||
try {
|
||||
yield { type: 'model-usage', requestId: input.requestId, callId: 'provider-call', runtime: 'model', provider: 'openai',
|
||||
model: 'fake', inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
if (calls === 1) {
|
||||
yield { type: 'text', requestId: input.requestId, delta: 'discard partial {' }
|
||||
if (typeof failure === 'string') yield { type: 'error', status: 'failed', requestId: input.requestId, message: failure }
|
||||
else throw failure
|
||||
} else {
|
||||
expect(f.release).toHaveBeenCalledTimes(1)
|
||||
expect(signal.aborted).toBe(false)
|
||||
yield { type: 'text', requestId: input.requestId, delta: 'clean result' }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
}
|
||||
} finally { finalized() }
|
||||
})
|
||||
const result = f.start()
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(calls).toBe(1)
|
||||
expect(f.release).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await expect(result).resolves.toBe('clean result')
|
||||
expect(calls).toBe(2)
|
||||
expect(finalized).toHaveBeenCalledTimes(2)
|
||||
expect(f.release).toHaveBeenCalledTimes(2)
|
||||
expect(new Set(f.release.mock.calls.map(args => args[0])).size).toBe(2)
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
expect(f.statuses.mock.calls.map(args => args[1])).toEqual(['failed', 'completed'])
|
||||
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 2, input: 20, output: 4 })
|
||||
expect(f.db.listTasks()).toEqual([])
|
||||
await expect(f.pool.run(async () => 'released')).resolves.toBe('released')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('makes exactly two calls on persistent transient failure and releases the pool', async () => {
|
||||
const failure = Object.assign(new Error('Unavailable'), { status: 503 })
|
||||
const f = fixture(async function* (input) { yield { type: 'status', requestId: input.requestId, message: 'starting' }; throw failure })
|
||||
const result = expect(f.start()).rejects.toBe(failure)
|
||||
await vi.runAllTimersAsync()
|
||||
await result
|
||||
expect(f.runtime.run).toHaveBeenCalledTimes(2)
|
||||
expect(f.release).toHaveBeenCalledTimes(2)
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
await expect(f.pool.run(async () => true)).resolves.toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
Object.assign(new Error('Unauthorized'), { status: 401 }),
|
||||
Object.assign(new Error('Forbidden'), { status: 403 }),
|
||||
Object.assign(new Error('Not implemented'), { status: 501 }),
|
||||
Object.assign(new Error('Insufficient quota'), { status: 429 }),
|
||||
Object.assign(new Error('Context length exceeded'), { status: 503 }),
|
||||
Object.assign(new Error('Invalid API key'), { status: 429 }),
|
||||
new Error('模型接口请求超时', { cause: new TypeError('terminated') }),
|
||||
new DOMException('aborted', 'AbortError'),
|
||||
new Error('模型接口返回了无效的流式 JSON'),
|
||||
new Error('Provider unavailable'),
|
||||
new Error('Configuration invalid'),
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('bad host'), { code: 'ENOTFOUND' }) }),
|
||||
new TypeError('fetch failed', { cause: Object.assign(new Error('bad certificate'), { code: 'CERT_HAS_EXPIRED' }) }),
|
||||
new Error('单次模型响应超过 100 KiB'),
|
||||
new Error('Unrelated error mentioning 429')
|
||||
])('does not retry nontransient errors, thrown or emitted: %s', async failure => {
|
||||
for (const emitted of [false, true]) {
|
||||
const f = fixture(async function* (input) {
|
||||
if (emitted && !failure.cause) yield { type: 'error', status: 'failed', requestId: input.requestId, message: failure.message }
|
||||
else throw failure
|
||||
})
|
||||
await expect(f.start()).rejects.toThrow(failure.message)
|
||||
expect(f.runtime.run).toHaveBeenCalledOnce()
|
||||
expect(f.release).toHaveBeenCalledOnce()
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels during the retry wait without dispatching another request', async () => {
|
||||
const f = fixture(async function* (input) { yield { type: 'error', status: 'failed', requestId: input.requestId, message: 'terminated' } })
|
||||
const result = expect(f.start()).rejects.toThrow()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(f.release).toHaveBeenCalledOnce()
|
||||
f.pool.cancelAll(new Error('User cancelled'))
|
||||
await result
|
||||
await vi.runAllTimersAsync()
|
||||
expect(f.runtime.run).toHaveBeenCalledOnce()
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
await expect(f.pool.run(async () => true)).resolves.toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not retry cancellation disguised as a transient stream error', async () => {
|
||||
const f = fixture(async function* (input) {
|
||||
yield { type: 'status', requestId: input.requestId, message: 'starting' }
|
||||
f.pool.cancelAll(new Error('User cancelled'))
|
||||
throw new TypeError('terminated')
|
||||
})
|
||||
await expect(f.start()).rejects.toThrow('User cancelled')
|
||||
expect(f.runtime.run).toHaveBeenCalledOnce()
|
||||
expect(f.statuses).toHaveBeenLastCalledWith(expect.any(String), 'cancelled', 'User cancelled')
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps a per-attempt timeout and never retries timeout (transient first: %s)', async transientFirst => {
|
||||
let calls = 0
|
||||
const f = fixture(async function* (input, signal) {
|
||||
calls++
|
||||
if (transientFirst && calls === 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 900))
|
||||
throw new TypeError('terminated')
|
||||
}
|
||||
await new Promise<void>((_, reject) => signal.addEventListener('abort', () => reject(new TypeError('terminated')), { once: true }))
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
}, 1)
|
||||
const result = expect(f.start()).rejects.toThrow('超过 1 秒')
|
||||
await vi.advanceTimersByTimeAsync(transientFirst ? 2300 : 900)
|
||||
expect(calls).toBe(transientFirst ? 2 : 1)
|
||||
expect(f.dispose).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await result
|
||||
expect(f.release).toHaveBeenCalledTimes(transientFirst ? 2 : 1)
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not retry a cancelled runtime error event with a transient-looking message', async () => {
|
||||
const f = fixture(async function* (input) {
|
||||
yield { type: 'error', status: 'cancelled', requestId: input.requestId, message: 'terminated' }
|
||||
})
|
||||
await expect(f.start()).rejects.toThrow('terminated')
|
||||
expect(f.runtime.run).toHaveBeenCalledOnce()
|
||||
expect(f.statuses).toHaveBeenLastCalledWith(expect.any(String), 'cancelled', 'terminated')
|
||||
expect(f.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not dispatch a retry if conversation cleanup fails', async () => {
|
||||
const f = fixture(async function* (input) {
|
||||
yield { type: 'error', status: 'failed', requestId: input.requestId, message: 'terminated' }
|
||||
})
|
||||
f.release.mockRejectedValueOnce(new Error('Cleanup failed'))
|
||||
await expect(f.start()).rejects.toThrow('Cleanup failed')
|
||||
expect(f.runtime.run).toHaveBeenCalledOnce()
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
await expect(f.pool.run(async () => true)).resolves.toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each([429, 500, 502, 503, 504])('retries actual direct-model HTTP %i with provider detail intact', async status => {
|
||||
const detail = status === 504 ? 'Gateway Timeout' : 'Provider detail'
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ error: { message: detail } }), { status }))
|
||||
.mockResolvedValueOnce(new Response('data: {"choices":[{"delta":{"content":"success"}}]}\n\ndata: [DONE]\n\n'))
|
||||
const runtime = new ModelAgentRuntime({ baseUrl: 'https://model.invalid', model: 'fake', authentication: 'none',
|
||||
protocol: 'openai-chat-completions', toolProvider: noModelTools, fetcher })
|
||||
const f = fixture(runtime.run.bind(runtime))
|
||||
f.release.mockImplementation(runtime.releaseConversation.bind(runtime))
|
||||
f.dispose.mockImplementation(runtime.dispose.bind(runtime))
|
||||
const result = expect(f.start()).resolves.toBe('success')
|
||||
await vi.runAllTimersAsync()
|
||||
await result
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(f.statuses.mock.calls[0]?.[2]).toBe(detail)
|
||||
expect(f.release).toHaveBeenCalledTimes(2)
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries an actual interrupted direct-model stream and retains its reported usage', async () => {
|
||||
let reads = 0
|
||||
const stream = new ReadableStream<Uint8Array>({ pull(controller) {
|
||||
if (reads++ === 0) controller.enqueue(new TextEncoder().encode(
|
||||
'data: {"choices":[{"delta":{"content":"discard me"}}],"usage":{"prompt_tokens":12,"completion_tokens":3}}\n\n'))
|
||||
else controller.error(new TypeError('terminated'))
|
||||
} })
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response(stream))
|
||||
.mockResolvedValueOnce(new Response('data: {"choices":[{"delta":{"content":"success"}}],"usage":{"prompt_tokens":14,"completion_tokens":4}}\n\ndata: [DONE]\n\n'))
|
||||
const runtime = new ModelAgentRuntime({ baseUrl: 'https://model.invalid', model: 'fake', authentication: 'none',
|
||||
protocol: 'openai-chat-completions', toolProvider: noModelTools, fetcher })
|
||||
const f = fixture(runtime.run.bind(runtime))
|
||||
f.release.mockImplementation(runtime.releaseConversation.bind(runtime))
|
||||
f.dispose.mockImplementation(runtime.dispose.bind(runtime))
|
||||
const result = expect(f.start()).resolves.toBe('success')
|
||||
await vi.runAllTimersAsync()
|
||||
await result
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(stream.locked).toBe(false)
|
||||
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 2, input: 26, output: 7 })
|
||||
expect(f.release).toHaveBeenCalledTimes(2)
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['openai-chat-completions', false], ['anthropic-messages', false],
|
||||
['openai-chat-completions', true], ['anthropic-messages', true]
|
||||
] as const)(
|
||||
'retains reported usage when %s output exceeds capacity without retrying (buffered=%s)', async (protocol, buffered) => {
|
||||
const text = 'x'.repeat(1024 * 1024 + 1)
|
||||
const events = protocol === 'anthropic-messages'
|
||||
? [{ type: 'message_start', message: { id: 'call', model: 'fake', usage: { input_tokens: 12, output_tokens: 3 } } },
|
||||
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }]
|
||||
: [{ choices: [{ delta: { content: text } }], usage: { prompt_tokens: 12, completion_tokens: 3 } }]
|
||||
let body!: ReadableStreamDefaultController<Uint8Array>
|
||||
const stream = new ReadableStream<Uint8Array>({ start(controller) { body = controller } })
|
||||
body.enqueue(new TextEncoder().encode(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')))
|
||||
if (buffered) {
|
||||
body.enqueue(new TextEncoder().encode(protocol === 'anthropic-messages' ? 'data: {"type":"message_stop"}\n\n' : 'data: [DONE]\n\n'))
|
||||
body.close()
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
if (!buffered) init!.signal!.addEventListener('abort', () => body.error(init!.signal!.reason), { once: true })
|
||||
return new Response(stream)
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({ baseUrl: 'https://model.invalid', model: 'fake', authentication: 'none',
|
||||
protocol, toolProvider: noModelTools, fetcher })
|
||||
const f = fixture(runtime.run.bind(runtime))
|
||||
f.release.mockImplementation(runtime.releaseConversation.bind(runtime))
|
||||
f.dispose.mockImplementation(runtime.dispose.bind(runtime))
|
||||
await expect(f.start()).rejects.toThrow('1024 KiB')
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(stream.locked).toBe(false)
|
||||
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 1, input: 12, output: 3 })
|
||||
expect(f.release).toHaveBeenCalledOnce()
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('drains usage only after overflow and preserves the cap error over later errors and timeout', async () => {
|
||||
let reason: unknown
|
||||
const finalized = vi.fn()
|
||||
const f = fixture(async function* (input, signal) {
|
||||
try {
|
||||
yield { type: 'text', requestId: input.requestId, delta: 'x'.repeat(1024 * 1024 + 1) }
|
||||
reason = signal.reason
|
||||
expect(signal.aborted).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
yield { type: 'text', requestId: input.requestId, delta: 'discard trailing text' }
|
||||
yield { type: 'model-usage', requestId: input.requestId, callId: 'call', runtime: 'model', provider: 'openai',
|
||||
model: 'fake', inputTokens: 12, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
yield { type: 'error', requestId: input.requestId, status: 'failed', message: 'terminated' }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
} finally { finalized() }
|
||||
}, 0.1)
|
||||
const pending = f.start().catch(error => error)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(await pending).toBe(reason)
|
||||
expect(reason).toBeInstanceOf(Error)
|
||||
expect((reason as Error).message).toContain('1024 KiB')
|
||||
expect(f.runtime.run).toHaveBeenCalledOnce()
|
||||
expect(finalized).toHaveBeenCalledOnce()
|
||||
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 1, input: 12, output: 3 })
|
||||
expect(f.release).toHaveBeenCalledOnce()
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds overflow accounting drain and ignores events arriving after cleanup', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
const finalized = vi.fn()
|
||||
const f = fixture(async function* (input) {
|
||||
try {
|
||||
yield { type: 'text', requestId: input.requestId, delta: 'x'.repeat(1024 * 1024 + 1) }
|
||||
await gate
|
||||
yield { type: 'model-usage', requestId: input.requestId, callId: 'late', runtime: 'model', provider: 'openai',
|
||||
model: 'fake', inputTokens: 12, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
} finally { finalized() }
|
||||
})
|
||||
const pending = f.start().catch(error => error)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(f.release).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(await pending).toMatchObject({ message: expect.stringContaining('1024 KiB') })
|
||||
expect(f.release).toHaveBeenCalledOnce()
|
||||
expect(f.dispose).toHaveBeenCalledOnce()
|
||||
await expect(f.pool.run(async () => 'released')).resolves.toBe('released')
|
||||
} finally { release(); await vi.advanceTimersByTimeAsync(1) }
|
||||
expect(finalized).toHaveBeenCalledOnce()
|
||||
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 0 })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -181,6 +181,7 @@ it('stops oversized runtime responses with a readable error while preserving res
|
||||
const f = fixture()
|
||||
f.respond.mockReturnValue({ ...empty, summary: 'x'.repeat(110_000) })
|
||||
await expect(f.service(100).run(f.request)).rejects.toThrow('单次模型响应超过 100 KiB')
|
||||
expect(f.respond).toHaveBeenCalledOnce()
|
||||
expect(f.db.listSupervisionResults()).toHaveLength(0)
|
||||
expect(f.db.listSupervisionActivity()[0]!.reviewProgress).toMatchObject({ batches: 0, remainingSources: 1 })
|
||||
const runId = f.db.listSupervisionActivity()[0]!.id
|
||||
@@ -211,7 +212,17 @@ function fixture(content = 'Atlas and Beacon are separate projects.') {
|
||||
const service = (responseKiB = 1024) => createProductionSupervisorService(db, async () => ({ supervisorModelConcurrency: 1,
|
||||
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB } }),
|
||||
async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), pool)
|
||||
return { db, request, respond, service }
|
||||
// Project ownership comes from timeline events, not the scope that first saved an entity.
|
||||
const associateCandidates = () => {
|
||||
const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase()
|
||||
const id = randomUUID()
|
||||
sql.prepare(`INSERT INTO supervision_events (id, story_line_id, result_id, occurred_at, title, description, event_type, project_id)
|
||||
SELECT ?, story_lines.id, supervision_results.id, ?, 'Seed ownership', '', 'discussion', ?
|
||||
FROM story_lines, supervision_results LIMIT 1`).run(id, request.timeRange.from, db.listProjects()[0]!.id)
|
||||
sql.prepare('INSERT INTO supervision_event_entities (event_id, entity_id, change_type) SELECT ?, id, ? FROM supervision_entities').run(id, 'associated')
|
||||
sql.prepare('INSERT INTO supervision_story_assigned VALUES (?)').run(id)
|
||||
}
|
||||
return { db, request, respond, service, associateCandidates }
|
||||
}
|
||||
|
||||
it('records supervisor model usage under a hidden supervision task', async () => {
|
||||
@@ -237,6 +248,7 @@ it('production factory normalizes two absent identities, then reuses exact candi
|
||||
const first = await f.service().run(f.request)
|
||||
expect(first.output.entities).toHaveLength(2)
|
||||
for (const entity of first.output.entities) expect(entity).not.toHaveProperty('persistedId')
|
||||
f.associateCandidates()
|
||||
const candidates = f.db.listSupervisionCandidates(f.request)
|
||||
expect(candidates).toHaveLength(2)
|
||||
expect(f.respond.mock.calls[0]![0]).toContain('KNOWN ENTITIES is empty. Every entity is new; omit candidateRef')
|
||||
@@ -252,10 +264,11 @@ it('production factory normalizes two absent identities, then reuses exact candi
|
||||
expect(f.respond).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains candidate mappings across leaf batches, failed merge and resume without replaying leaves', async () => {
|
||||
it.each([false, true])('retains candidate mappings across leaf batches, failed merge and resume without replaying leaves (transient: %s)', async transient => {
|
||||
const f = fixture('Atlas and Beacon. '.repeat(100))
|
||||
f.db.saveSupervisionResult({ request: f.request, evidence: [], output: { ...empty,
|
||||
entities: ['Atlas', 'Beacon'].map(label => ({ id: label, label, description: label, sourceReferenceIds: [] })) } })
|
||||
f.associateCandidates()
|
||||
const candidates = f.db.listSupervisionCandidates(f.request)
|
||||
const original = f.respond.getMockImplementation()!
|
||||
let failMerge = true
|
||||
@@ -263,13 +276,15 @@ it('retains candidate mappings across leaf batches, failed merge and resume with
|
||||
if (prompt.includes('These inputs are navigation summaries')) {
|
||||
expect(prompt).toContain('KNOWN ENTITIES:\n\n[]')
|
||||
expect(prompt).toContain('"entities":[]')
|
||||
if (failMerge) { failMerge = false; throw new Error('Merge interrupted') }
|
||||
if (failMerge) throw transient ? Object.assign(new Error('Merge interrupted'), { status: 503 }) : new Error('Merge interrupted')
|
||||
return empty
|
||||
}
|
||||
const output = original(prompt) as { entities: Array<{ label: string }> }
|
||||
return { ...output, entities: output.entities.map(entity => ({ ...entity, persistedId: candidates.find(candidate => candidate.label === entity.label)!.id })) }
|
||||
})
|
||||
await expect(f.service().run(f.request)).rejects.toThrow('Merge interrupted')
|
||||
expect(f.respond.mock.calls.filter(([prompt]) => prompt.includes('These inputs are navigation summaries'))).toHaveLength(transient ? 2 : 1)
|
||||
failMerge = false
|
||||
const runId = f.db.listSupervisionActivity().find(item => item.status === 'failed')!.id
|
||||
const saved = f.db.supervisionReviewStore().batches(runId)
|
||||
expect(saved).toHaveLength(2)
|
||||
@@ -346,6 +361,7 @@ it('reuses actual legacy text-key shapes through strict UUID aliases and preserv
|
||||
sql.prepare(`INSERT INTO supervision_entities (id, story_line_id, canonical_label, description, confirmation_state, updated_at, source_reference_ids_json)
|
||||
VALUES (?, ?, ?, 'Human description', 'confirmed', ?, '[]')`).run(id, String(story.id), id, new Date().toISOString())
|
||||
}
|
||||
f.associateCandidates()
|
||||
const candidates = f.db.listSupervisionCandidates(f.request)
|
||||
const legacy = candidates.filter(candidate => candidate.storageId)
|
||||
expect(legacy).toHaveLength(2)
|
||||
|
||||
@@ -20,7 +20,33 @@ type SupervisionModelDependencies = {
|
||||
persistUsage: (event: RuntimeModelUsageEvent) => void
|
||||
}
|
||||
|
||||
/** One bounded, tool-free text call that shares the supervisor concurrency pool. */
|
||||
function isTransientSupervisionError(error: unknown): boolean {
|
||||
let transient = false
|
||||
const seen = new Set<unknown>()
|
||||
for (let current = error; current && typeof current === 'object' && !seen.has(current);) {
|
||||
seen.add(current)
|
||||
const record = current as Record<string, unknown>
|
||||
const message = typeof record.message === 'string' ? record.message : ''
|
||||
const code = typeof record.code === 'string' ? record.code : ''
|
||||
// Direct-model errors retain status; runtime events carry only the public message.
|
||||
const status = record.status ?? record.statusCode ?? /\bHTTP\s+(\d{3})\b/iu.exec(message)?.[1]
|
||||
const failureText = Number(status) === 504 ? message.replace(/\bgateway (?:timeout|timed out)\b/giu, '') : message
|
||||
if (record.name === 'AbortError' || record.name === 'TimeoutError' ||
|
||||
/^(?:ENOTFOUND|ERR_INVALID_URL|CERT_.*|.*CERTIFICATE.*|DEPTH_ZERO_SELF_SIGNED_CERT)$/u.test(code) ||
|
||||
/abort|cancel|timed?\s*out|timeout|超时|取消|容量|context[_ ](?:length|window)|max[_ ]tokens|quota|billing|insufficient|capacity|api[_ -]?key|unauthori[sz]ed|forbidden|authentication|configuration/iu.test(`${code} ${failureText}`)) return false
|
||||
if (status !== undefined) {
|
||||
if (![429, 500, 502, 503, 504].includes(Number(status))) return false
|
||||
transient = true
|
||||
}
|
||||
if (/^(?:ECONNRESET|ECONNREFUSED|EPIPE|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|UND_ERR_SOCKET)$/u.test(code) ||
|
||||
/^(?:TypeError:\s*)?(?:terminated|fetch failed|network error|socket hang up)$/iu.test(message) ||
|
||||
message === '模型接口流式响应意外中断' || /^rate limit (?:exceeded|reached)\b/iu.test(message)) transient = true
|
||||
current = record.cause
|
||||
}
|
||||
return transient
|
||||
}
|
||||
|
||||
/** One bounded, tool-free text call, with one transient retry in the supervisor pool. */
|
||||
async function runSupervisionModel(dependencies: SupervisionModelDependencies, request: {
|
||||
prompt: string
|
||||
title: string
|
||||
@@ -36,49 +62,82 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
return pool.run(async signal => {
|
||||
const timeoutSeconds = request.timeoutSeconds ?? settings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||
const runtime = await resolveRuntime()
|
||||
const controller = new AbortController()
|
||||
const modelSignal = AbortSignal.any([signal, controller.signal, ...(request.signal ? [request.signal] : [])])
|
||||
modelSignal.throwIfAborted()
|
||||
let timeoutError: Error | undefined
|
||||
const timeout = setTimeout(() => {
|
||||
timeoutError = new Error(request.timeoutMessage(timeoutSeconds))
|
||||
controller.abort(timeoutError)
|
||||
}, timeoutSeconds * 1000)
|
||||
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
|
||||
let output = '', completed = false
|
||||
const requestId = randomUUID()
|
||||
const conversationId = `supervision:${requestId}`
|
||||
// Hidden task row so model usage can be attributed to supervision.
|
||||
database.createTask({ id: requestId, conversationId, title: request.title, instructions: request.instructions,
|
||||
origin: 'assistant', visible: false })
|
||||
try {
|
||||
for await (const event of runtime.run({ requestId, conversationId, prompt: request.prompt },
|
||||
modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' })) {
|
||||
if (event.type === 'text') {
|
||||
output += event.delta
|
||||
if (Buffer.byteLength(output) > responseKiB * 1024) {
|
||||
controller.abort(new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`))
|
||||
modelSignal.throwIfAborted()
|
||||
}
|
||||
}
|
||||
if (event.type === 'model-usage') persistUsage(event)
|
||||
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
|
||||
if (event.type === 'error') throw new Error(event.message)
|
||||
if (event.type === 'done') completed = true
|
||||
}
|
||||
modelSignal.throwIfAborted()
|
||||
if (!completed) throw new Error('监督者模型未报告完成')
|
||||
database.updateTaskStatus(requestId, 'completed')
|
||||
return output
|
||||
} catch (error) {
|
||||
const failure = timeoutError ?? error
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
signal.throwIfAborted()
|
||||
const controller = new AbortController()
|
||||
const modelSignal = AbortSignal.any([signal, controller.signal])
|
||||
let timeoutError: Error | undefined
|
||||
const timeout = setTimeout(() => {
|
||||
timeoutError = new Error(request.timeoutMessage(timeoutSeconds))
|
||||
controller.abort(timeoutError)
|
||||
}, timeoutSeconds * 1000)
|
||||
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
|
||||
let output = '', completed = false
|
||||
let overflowError: Error | undefined
|
||||
let drainTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let drainDeadline: Promise<never> | undefined
|
||||
let events: ReturnType<AgentRuntime['run']> | undefined
|
||||
const requestId = randomUUID()
|
||||
const conversationId = `supervision:${requestId}`
|
||||
try {
|
||||
database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed',
|
||||
failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败')
|
||||
} catch { /* status bookkeeping must not mask the model failure */ }
|
||||
throw failure
|
||||
// Each attempt has its own hidden task so reported usage survives retries.
|
||||
database.createTask({ id: requestId, conversationId, title: request.title, instructions: request.instructions,
|
||||
origin: 'assistant', visible: false })
|
||||
events = runtime.run({ requestId, conversationId, prompt: request.prompt },
|
||||
modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' })
|
||||
while (true) {
|
||||
const next = events.next()
|
||||
const step = await (drainDeadline ? Promise.race([next, drainDeadline]) : next)
|
||||
if (step.done) break
|
||||
const event = step.value
|
||||
if (event.type === 'model-usage') persistUsage(event)
|
||||
if (overflowError) continue
|
||||
if (event.type === 'text') {
|
||||
if (Buffer.byteLength(output + event.delta) > responseKiB * 1024) {
|
||||
overflowError = new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`)
|
||||
output = ''
|
||||
controller.abort(overflowError)
|
||||
// Let the aborted stream emit usage, but never retain further text
|
||||
// or let an unresponsive iterator hold the supervisor pool forever.
|
||||
drainDeadline = new Promise<never>((_resolve, reject) => {
|
||||
drainTimeout = setTimeout(() => reject(overflowError), 1_000)
|
||||
})
|
||||
} else output += event.delta
|
||||
}
|
||||
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
|
||||
if (event.type === 'error') {
|
||||
const error = new Error(event.message)
|
||||
if (event.status === 'cancelled') controller.abort(error)
|
||||
throw error
|
||||
}
|
||||
if (event.type === 'done') completed = true
|
||||
}
|
||||
modelSignal.throwIfAborted()
|
||||
if (!completed) throw new Error('监督者模型未报告完成')
|
||||
database.updateTaskStatus(requestId, 'completed')
|
||||
return output
|
||||
} catch (error) {
|
||||
const failure = overflowError ?? timeoutError ?? (modelSignal.aborted ? modelSignal.reason : error)
|
||||
try {
|
||||
database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed',
|
||||
failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败')
|
||||
} catch { /* status bookkeeping must not mask the model failure */ }
|
||||
if (attempt > 0 || modelSignal.aborted || !isTransientSupervisionError(failure)) throw failure
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
clearTimeout(drainTimeout)
|
||||
controller.abort()
|
||||
const closing = events?.return().catch(() => undefined)
|
||||
if (!overflowError) await closing
|
||||
await runtime.releaseConversation?.(conversationId)
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const abort = () => { clearTimeout(wait); reject(signal.reason) }
|
||||
const wait = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 500)
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
}
|
||||
finally { clearTimeout(timeout); await runtime.releaseConversation?.(conversationId) }
|
||||
}, request.signal)
|
||||
}
|
||||
|
||||
@@ -126,7 +185,7 @@ export function createProductionSupervisorService(
|
||||
background: request => database.reviewBackground(request.scope),
|
||||
start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId),
|
||||
fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId),
|
||||
candidates: request => database.supervisionCandidates(request), save: async result => database.saveSupervisionResult(result)
|
||||
candidates: (request, batch) => database.supervisionCandidates(request, batch), save: async result => database.saveSupervisionResult(result)
|
||||
}, { database: () => database.supervisionReviewStore(),
|
||||
initialize: (runId, state, signal) => database.initializeSupervisionReview(runId, state, signal),
|
||||
resume: (runId, signal) => database.resumeSupervisionReview(runId, signal),
|
||||
|
||||
@@ -9,7 +9,9 @@ import type { SupervisionReviewBatch, SupervisionReviewProgress, SupervisionRevi
|
||||
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
|
||||
|
||||
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
|
||||
export class ReviewSourcesOmitted extends Error {}
|
||||
|
||||
// Only the manifest is materialized. Source bodies are read in bounded substrings.
|
||||
export const supervisionReviewMigration = `
|
||||
@@ -98,7 +100,8 @@ export class SupervisionReviewStore {
|
||||
this.db.exec('DROP TABLE IF EXISTS temp.review_manifest; BEGIN')
|
||||
try {
|
||||
this.db.prepare(`CREATE TEMP TABLE review_manifest AS
|
||||
SELECT s.source, review_revision(s.context) AS revision, s.project_id, s.conversation_id, s.sequence,
|
||||
SELECT s.source, review_revision(s.context) AS revision, s.project_id,
|
||||
CASE WHEN s.type = 'task' THEN COALESCE((SELECT t.conversation_id FROM tasks t JOIN conversations c ON c.id = t.conversation_id WHERE t.id = s.record_id), '') ELSE s.conversation_id END AS conversation_id, s.sequence,
|
||||
length(s.body) AS length, CASE WHEN c.revision = review_revision(s.context) THEN c.processed_offset ELSE 0 END AS initial_offset
|
||||
FROM supervision_review_current s LEFT JOIN review_checkpoints c
|
||||
ON ? = 1 AND c.stage = 'supervisor' AND c.scope = '${TIMELINE_CHECKPOINT_SCOPE}' AND c.source = s.source
|
||||
@@ -130,6 +133,7 @@ export class SupervisionReviewStore {
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.initializing') WHERE run_id = ?").run(runId)
|
||||
this.omitDeletedConversations(runId)
|
||||
} finally { this.db.exec('DROP TABLE IF EXISTS temp.review_manifest') }
|
||||
}
|
||||
|
||||
@@ -144,13 +148,17 @@ export class SupervisionReviewStore {
|
||||
unfinished(request: SupervisionRunRequest): string | undefined {
|
||||
return this.db.prepare(`SELECT s.id FROM supervision_runs s JOIN supervision_review_runs r ON r.run_id = s.id
|
||||
WHERE s.trigger = 'heartbeat' AND s.scope_json = ? AND s.status IN ('failed', 'running')
|
||||
AND COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0
|
||||
AND (COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0 OR EXISTS (
|
||||
SELECT 1 FROM supervision_review_sources source WHERE source.run_id = s.id AND source.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = source.conversation_id)
|
||||
AND instr(s.error, '(' || source.source || ');') > 0))
|
||||
ORDER BY s.created_at LIMIT 1`).get(JSON.stringify(request.scope))?.id as string | undefined
|
||||
}
|
||||
|
||||
resume(runId: string, signal?: AbortSignal): void {
|
||||
const status = this.db.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)?.status
|
||||
if (status === 'cancelled') throw new Error('SUPERVISION_REVIEW_CANCELLED: 此回顾已取消,不能继续。请开始新的回顾。')
|
||||
this.omitDeletedConversations(runId)
|
||||
if (this.load(runId).restartRequired) throw new Error('Review source changed or was removed; start a new review')
|
||||
const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'running', error = NULL, completed_at = NULL
|
||||
WHERE id = ? AND status IN ('paused', 'failed', 'running')`).run(runId)
|
||||
@@ -159,7 +167,7 @@ export class SupervisionReviewStore {
|
||||
this.initializeSources(runId, this.load(runId), signal)
|
||||
return
|
||||
}
|
||||
// A changed/deleted source cannot be silently counted as covered. Saved facts remain accessible.
|
||||
// Existing sources must still match their frozen versions.
|
||||
let after = ''
|
||||
for (;;) {
|
||||
const rows = this.db.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source > ? ORDER BY source LIMIT 200').all(runId, after)
|
||||
@@ -167,6 +175,7 @@ export class SupervisionReviewStore {
|
||||
for (const row of rows) {
|
||||
signal?.throwIfAborted()
|
||||
if (this.currentSource(String(row.source), 0, 0)?.current_revision !== row.revision) {
|
||||
if (this.omitDeletedConversations(runId)) return this.resume(runId, signal)
|
||||
this.sourceChanged(runId, String(row.source))
|
||||
}
|
||||
after = String(row.source)
|
||||
@@ -209,6 +218,7 @@ export class SupervisionReviewStore {
|
||||
}
|
||||
|
||||
chunk(runId: string, projectId: string, conversationId: string, config: ReviewConfiguration): SupervisionEvidence[] {
|
||||
this.omitDeletedConversations(runId)
|
||||
const { request } = this.load(runId)
|
||||
const evidence: SupervisionEvidence[] = []
|
||||
let remaining = config.batchCharacters
|
||||
@@ -226,7 +236,10 @@ export class SupervisionReviewStore {
|
||||
if (!rows.length) break
|
||||
for (const metadata of rows) {
|
||||
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
|
||||
if (!current || current.current_revision !== metadata.revision) this.sourceChanged(runId, metadata.source)
|
||||
if (!current || current.current_revision !== metadata.revision) {
|
||||
if (this.omitDeletedConversations(runId)) return this.chunk(runId, projectId, conversationId, config)
|
||||
this.sourceChanged(runId, metadata.source)
|
||||
}
|
||||
const row = { ...metadata, ...current }
|
||||
const locator = JSON.parse(row.locator) as Record<string, unknown>
|
||||
const messageId = String(locator.messageId ?? row.source)
|
||||
@@ -278,10 +291,13 @@ export class SupervisionReviewStore {
|
||||
save(runId: string, projectId: string, conversationId: string, evidence: SupervisionEvidence[], output: SupervisionSummaryOutput): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.omitDeletedConversations(runId)
|
||||
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
|
||||
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
|
||||
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
|
||||
for (const item of evidence) {
|
||||
const locator = item.locator!
|
||||
if (this.currentSource(String(locator.source), 0, 0)?.current_revision !== locator.revision) this.sourceChanged(runId, String(locator.source))
|
||||
if (advance.run(Number(locator.end), runId, String(locator.source), String(locator.revision), Number(locator.start)).changes !== 1) {
|
||||
throw new Error('Review coverage has a gap or duplicate batch')
|
||||
}
|
||||
@@ -293,6 +309,11 @@ export class SupervisionReviewStore {
|
||||
} catch (error) { this.db.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
|
||||
hasSources(runId: string, evidence: SupervisionEvidence[]): boolean {
|
||||
const source = this.db.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
|
||||
return evidence.every(item => source.get(runId, String(item.locator!.source)))
|
||||
}
|
||||
|
||||
batches(runId: string, limit = 10, offset = 0): SupervisionReviewBatch[] {
|
||||
return this.db.prepare(`SELECT * FROM supervision_review_batches WHERE run_id = ? ORDER BY project_id, conversation_id, rowid LIMIT ? OFFSET ?`)
|
||||
.all(runId, limit, offset).map(row => ({ id: String(row.id), projectId: String(row.project_id), conversationId: String(row.conversation_id),
|
||||
@@ -305,16 +326,76 @@ export class SupervisionReviewStore {
|
||||
const batches = this.db.prepare(`SELECT COUNT(*) AS batches, COALESCE(SUM(characters), 0) AS characters
|
||||
FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
|
||||
const state = this.load(runId)
|
||||
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
|
||||
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
|
||||
const deletedFailure = failedSource && this.db.prepare(`SELECT 1 FROM supervision_review_sources s
|
||||
WHERE s.run_id = ? AND s.source = ? AND s.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).get(runId, failedSource)
|
||||
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
|
||||
return { runId, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
return { runId, omittedSources: state.omittedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: deletedFailure ? false : state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
|
||||
}
|
||||
|
||||
assertComplete(runId: string): void {
|
||||
assertComplete(runId: string, expectedOmissions?: number): void {
|
||||
if (this.omitDeletedConversations(runId) || (expectedOmissions !== undefined && (this.load(runId).omittedSources ?? 0) !== expectedOmissions)) {
|
||||
throw new ReviewSourcesOmitted('Deleted conversation sources omitted; rebuild review navigation')
|
||||
}
|
||||
if (this.load(runId).initializing) throw new Error('Cannot publish an incomplete source manifest')
|
||||
if (this.progress(runId).remainingSources) throw new Error('Cannot publish incomplete coverage')
|
||||
}
|
||||
|
||||
/** Only unpublished staging is disposable. No source bodies are read or restored here. */
|
||||
omitDeletedConversations(runId: string): boolean {
|
||||
if (!this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('running', 'paused', 'failed')").get(runId)) return false
|
||||
if (this.load(runId).initializing) return false
|
||||
const deleted = this.db.prepare(`SELECT s.source FROM supervision_review_sources s
|
||||
WHERE s.run_id = ? AND s.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).all(runId)
|
||||
if (!deleted.length) return false
|
||||
this.db.exec('SAVEPOINT omit_review_sources')
|
||||
try {
|
||||
const removed = new Set(deleted.map(row => String(row.source)))
|
||||
const affected = new Set(removed)
|
||||
const invalid = new Set<string>()
|
||||
// A mixed leaf cannot be filtered safely: its prose may combine every input.
|
||||
// Reset its surviving sources and discard their other leaves to avoid duplicate coverage.
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const batches = this.db.prepare(`SELECT b.id, json_group_array(json_extract(item.value, '$.locator.source')) AS sources
|
||||
FROM (SELECT id, evidence_json FROM supervision_review_batches WHERE run_id = ? ORDER BY rowid LIMIT 10 OFFSET ?) b,
|
||||
json_each(b.evidence_json) item GROUP BY b.id`).all(runId, offset)
|
||||
if (!batches.length) break
|
||||
for (const batch of batches) {
|
||||
const sources = JSON.parse(String(batch.sources)) as string[]
|
||||
if (invalid.has(String(batch.id)) || !sources.some(source => affected.has(source))) continue
|
||||
invalid.add(String(batch.id))
|
||||
for (const source of sources) affected.add(source)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const invalidIds = JSON.stringify([...invalid])
|
||||
this.db.prepare(`WITH RECURSIVE invalid(id) AS (
|
||||
SELECT value FROM json_each(?) UNION
|
||||
SELECT n.id FROM supervision_review_navigation n, json_each(n.children_json) child
|
||||
JOIN invalid i ON i.id = child.value WHERE n.run_id = ?
|
||||
) DELETE FROM supervision_review_navigation WHERE run_id = ? AND id IN (SELECT id FROM invalid)`)
|
||||
.run(invalidIds, runId, runId)
|
||||
this.db.prepare('DELETE FROM supervision_review_batches WHERE run_id = ? AND id IN (SELECT value FROM json_each(?))').run(runId, invalidIds)
|
||||
for (const source of affected) this.db.prepare('UPDATE supervision_review_sources SET processed_offset = initial_offset WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
for (const source of removed) this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
|
||||
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
|
||||
if (failedSource && removed.has(failedSource)) this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.restartRequired') WHERE run_id = ?").run(runId)
|
||||
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(state_json,
|
||||
'$.omittedSources', COALESCE(json_extract(state_json, '$.omittedSources'), 0) + ?, '$.phase', 'extracting') WHERE run_id = ?`).run(removed.size, runId)
|
||||
this.db.exec('RELEASE omit_review_sources')
|
||||
return true
|
||||
} catch (error) { this.db.exec('ROLLBACK TO omit_review_sources; RELEASE omit_review_sources'); throw error }
|
||||
}
|
||||
|
||||
commitCheckpoints(runId: string, request: SupervisionRunRequest): void {
|
||||
this.assertComplete(runId)
|
||||
if (!this.db.isTransaction) throw new Error('Review publication must be transactional')
|
||||
|
||||
@@ -433,3 +433,128 @@ it('does not let a heartbeat silently resume a review the user paused', async ()
|
||||
expect(next.runId).not.toBe(pausedId)
|
||||
expect(f.db.listSupervisionActivity().find(row => row.id === pausedId)?.status).toBe('paused')
|
||||
})
|
||||
|
||||
it('omits a deleted conversation, its knowledge and tasks before resume without changing published history', async () => {
|
||||
const f = await fixture([['Delete me'], ['Keep me']])
|
||||
const deleted = f.conversations[0]!
|
||||
f.sql.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify({ sourceReferences: [
|
||||
{ documentId: 'doc', documentName: 'Reference', snippet: 'Deleted knowledge', chunkId: 'chunk' }
|
||||
] }), deleted.messages[0]!.id)
|
||||
const taskId = randomUUID()
|
||||
f.db.createTask({ id: taskId, conversationId: deleted.id, projectId: deleted.projectId,
|
||||
title: 'Deleted task', instructions: 'Test', origin: 'user', visible: true })
|
||||
f.db.updateTaskStatus(taskId, 'completed')
|
||||
f.sql.prepare('UPDATE tasks SET created_at = ?, completed_at = ? WHERE id = ?').run(new Date(time).toISOString(), new Date(time).toISOString(), taskId)
|
||||
await f.service().run(request)
|
||||
const history = f.sql.prepare('SELECT * FROM supervision_results').all()
|
||||
const historySources = f.sql.prepare('SELECT * FROM supervision_sources ORDER BY id').all()
|
||||
const historyBatches = f.sql.prepare('SELECT * FROM supervision_review_batches ORDER BY id').all()
|
||||
f.sql.exec("CREATE TRIGGER fail_publication BEFORE INSERT ON supervision_results BEGIN SELECT RAISE(ABORT, 'Publication failed'); END")
|
||||
await expect(f.service().run(request)).rejects.toThrow('Publication failed')
|
||||
const runId = String(f.sql.prepare("SELECT id FROM supervision_runs WHERE status = 'failed'").get()!.id)
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const kept = store.batches(runId).filter(batch => batch.conversationId !== deleted.id)
|
||||
expect(f.db.deleteLocalConversation(deleted.id)).toBe(true)
|
||||
expect(store.progress(runId)).toMatchObject({ omittedSources: 3, sources: 1, batches: kept.length, restartRequired: undefined })
|
||||
expect(store.batches(runId)).toEqual(kept)
|
||||
f.sql.exec('DROP TRIGGER fail_publication')
|
||||
const calls = f.summarize.mock.calls.length
|
||||
f.db.close(); f.db.initialize(f.directory)
|
||||
const result = await f.service().resume(runId)
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 3, sources: 1, complete: true } })
|
||||
expect(f.summarize).toHaveBeenCalledTimes(calls)
|
||||
expect(f.sql.prepare('SELECT * FROM supervision_results WHERE id = ?').get(String(history[0]!.id))).toEqual(history[0])
|
||||
expect(f.sql.prepare('SELECT * FROM supervision_sources WHERE result_id = ? ORDER BY id').all(String(history[0]!.id))).toEqual(historySources)
|
||||
expect(f.sql.prepare('SELECT * FROM supervision_review_batches WHERE run_id = ? ORDER BY id').all(String(history[0]!.run_id))).toEqual(historyBatches)
|
||||
expect(() => f.db.getConversation(deleted.id)).toThrow('对话不存在')
|
||||
expect(f.db.listSupervisionActivity().find(row => row.id === runId)?.reviewProgress?.omittedSources).toBe(3)
|
||||
})
|
||||
|
||||
it.each(['leaf', 'navigation', 'publication', 'publication from another connection'] as const)('continues after deletion during %s without publishing deleted input', async phase => {
|
||||
const f = await fixture([['Delete me'], ['Keep me']], { concurrency: 2 })
|
||||
const base = f.summarize.getMockImplementation()!
|
||||
f.summarize.mockImplementation(async input => ({ ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }))
|
||||
let deletedId: string | undefined
|
||||
const remove = () => {
|
||||
deletedId = f.conversations[0]!.id
|
||||
if (phase === 'publication from another connection') f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(deletedId)
|
||||
else f.db.deleteLocalConversation(deletedId)
|
||||
}
|
||||
if (phase.startsWith('publication')) {
|
||||
const save = f.db.saveSupervisionResult.bind(f.db)
|
||||
vi.spyOn(f.db, 'saveSupervisionResult').mockImplementationOnce(result => { remove(); save(result) })
|
||||
} else f.summarize.mockImplementation(async input => {
|
||||
if (!deletedId && (phase === 'leaf' ? input.evidence.some(item => item.locator?.conversationId === f.conversations[0]!.id) : input.evidence[0]?.sourceType === 'note')) remove()
|
||||
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
|
||||
})
|
||||
const result = await f.service().run({ ...request, reanalyze: undefined })
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 1, sources: 1, batches: 1, complete: true } })
|
||||
expect(result.output.summary).toBe('Keep me')
|
||||
expect(f.db.listSupervisionResults()[0]!.summary).toBe('Keep me')
|
||||
const sources = f.sql.prepare('SELECT locator_json FROM supervision_sources').all()
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(JSON.parse(String(sources[0]!.locator_json)).conversationId).toBe(f.conversations[1]!.id)
|
||||
expect(f.sql.prepare('SELECT source FROM review_checkpoints').all()).toEqual([{ source: `message:${f.conversations[1]!.messages[0]!.id}` }])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_navigation').get()!.n).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['before resume', 'in flight'] as const)('finishes with an explicit omission and no empty report when every source is deleted %s', async when => {
|
||||
const f = await fixture([['Only source']], { concurrency: 1 })
|
||||
const service = f.service()
|
||||
let runId: string
|
||||
if (when === 'before resume') {
|
||||
f.summarize.mockRejectedValueOnce(new Error('Provider unavailable'))
|
||||
await expect(service.run(request)).rejects.toThrow('Provider unavailable')
|
||||
runId = f.db.listSupervisionActivity()[0]!.id
|
||||
f.db.deleteLocalConversation(f.conversations[0]!.id)
|
||||
} else {
|
||||
f.summarize.mockImplementationOnce(async () => { f.db.deleteLocalConversation(f.conversations[0]!.id); return empty })
|
||||
const result = await service.run(request)
|
||||
runId = result.runId!
|
||||
expect(result).toMatchObject({ status: 'no_change', coverage: { omittedSources: 1, sources: 0, complete: true } })
|
||||
}
|
||||
if (when === 'before resume') await expect(service.resume(runId)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1, sources: 0, complete: true } })
|
||||
expect(f.db.listSupervisionResults()).toEqual([])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('resets mixed-leaf survivors only and invalidates their dependent navigation transitively', async () => {
|
||||
const f = await fixture([['Deleted'], ['Survivor'], ['Unrelated']], { concurrency: 1 })
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
store.initialize(runId, { request, config: f.config })
|
||||
const evidence = f.conversations.map(c => store.chunk(runId, c.projectId, c.id, f.config))
|
||||
store.save(runId, f.conversations[0]!.projectId, '', [...evidence[0]!, ...evidence[1]!], empty)
|
||||
store.save(runId, f.conversations[2]!.projectId, f.conversations[2]!.id, evidence[2]!, empty)
|
||||
const batches = store.batches(runId)
|
||||
const mixed = batches.find(b => b.evidence.length === 2)!, kept = batches.find(b => b.evidence.length === 1)!
|
||||
store.saveNavigation(runId, 'affected', [mixed.id], empty)
|
||||
store.saveNavigation(runId, 'unaffected', [kept.id], empty)
|
||||
store.saveNavigation(runId, 'root', ['affected', 'unaffected'], empty)
|
||||
f.db.deleteLocalConversation(f.conversations[0]!.id)
|
||||
expect(store.batches(runId)).toEqual([kept])
|
||||
expect(store.navigation(runId, 'affected')).toBeUndefined()
|
||||
expect(store.navigation(runId, 'root')).toBeUndefined()
|
||||
expect(store.navigation(runId, 'unaffected')).toEqual(empty)
|
||||
expect(store.progress(runId)).toMatchObject({ sources: 2, remainingSources: 1, omittedSources: 1 })
|
||||
await f.service().resume(runId)
|
||||
expect(f.summarize.mock.calls.filter(([input]) => input.evidence[0]?.sourceType !== 'note').map(([input]) => input.evidence.map(e => e.content))).toEqual([['Survivor']])
|
||||
expect(store.batches(runId)).toContainEqual(kept)
|
||||
})
|
||||
|
||||
it('recovers the earlier deletion failure but keeps archived and modified existing conversations as explicit failures', async () => {
|
||||
const f = await fixture([['Source']])
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const id = f.db.startSupervisionRun(request)
|
||||
store.initialize(id, { request, config: f.config })
|
||||
f.sql.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.restartRequired', json('true')) WHERE run_id = ?").run(id)
|
||||
f.db.failSupervisionRun(id, `Review source changed or was removed (message:${f.conversations[0]!.messages[0]!.id}); start a new review`)
|
||||
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
|
||||
expect(store.progress(id).restartRequired).toBe(false)
|
||||
await expect(f.service().resume(id)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1 } })
|
||||
const g = await fixture([['Archived source']])
|
||||
g.summarize.mockRejectedValueOnce(new Error('Pause'))
|
||||
await expect(g.service().run(request)).rejects.toThrow('Pause')
|
||||
g.sql.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?").run(g.conversations[0]!.id)
|
||||
await expect(g.service().resume(g.db.listSupervisionActivity()[0]!.id)).rejects.toThrow('source changed')
|
||||
})
|
||||
|
||||
@@ -247,3 +247,37 @@ it('runs after publication inside the review and records failure without failing
|
||||
expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Timeline'])
|
||||
await expect(service.run(request)).resolves.toMatchObject({ status: 'no_change', coverage: { stories: { status: 'completed', calls: 0 } } })
|
||||
})
|
||||
|
||||
it.each(['remove', 'merge'] as const)('rejects an assignment whose selected story was changed by %s while awaiting the model', async action => {
|
||||
const f = await fixture()
|
||||
f.publish(f.a.id, ['First', 'Second'])
|
||||
await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Alpha' }, { key: 'new_2', level: 'feature', name: 'Beta' }],
|
||||
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_2' }] }), { kind: 'global' }, options)
|
||||
const [alpha, beta] = f.stories.list({ kind: 'global' })
|
||||
f.publish(f.a.id, ['New evidence'], 22)
|
||||
const model = async () => {
|
||||
f.stories.act(action === 'remove' ? { action, storyId: alpha!.id } : { action, storyId: alpha!.id, intoId: beta!.id })
|
||||
return JSON.stringify({ stories: [{ key: 'new_1', level: 'feature', name: 'Must roll back' }], assignments: [{ event: 'e_1', story: 'story_1' }] })
|
||||
}
|
||||
await expect(assignStories(f.stories, model, { kind: 'global' }, options)).rejects.toThrow('Story changed')
|
||||
expect(f.stories.pending({ kind: 'global' })).toHaveLength(1)
|
||||
expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Beta'])
|
||||
expect(f.sql.prepare("SELECT COUNT(*) AS n FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE s.status != 'current'").get()!.n).toBe(0)
|
||||
await assignStories(f.stories, answer({ assignments: [{ event: 'e_1', story: 'story_1' }] }), { kind: 'global' }, { ...options, threadEvents: 100 })
|
||||
expect(f.stories.pending({ kind: 'global' })).toEqual([])
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['feature', 'thread'] as const)('rejects a split after its %s was removed without leaving new threads', async removed => {
|
||||
const f = await fixture()
|
||||
f.publish(f.a.id, ['One', 'Two', 'Three', 'Four'])
|
||||
await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Feature' }, { key: 'new_2', level: 'thread', name: 'Thread', parent: 'new_1' }],
|
||||
assignments: [1, 2, 3, 4].map(n => ({ event: `e_${n}`, story: n === 4 ? 'new_2' : 'new_1' })) }), { kind: 'global' }, { ...options, threadEvents: 4 })
|
||||
const [feature, thread] = f.stories.list({ kind: 'global' })
|
||||
const input = f.stories.splitInput(feature!.id, 8000)
|
||||
f.stories.act({ action: 'remove', storyId: removed === 'feature' ? feature!.id : thread!.id })
|
||||
const before = f.stories.list({ kind: 'global' })
|
||||
expect(() => f.stories.split(input, { threads: [], moves: [{ event: 'e_1', thread: 'thread_1' }] }, 4)).toThrow('Story changed')
|
||||
expect(f.stories.list({ kind: 'global' })).toEqual(before)
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -236,6 +236,7 @@ export class SupervisionStoryStore {
|
||||
VALUES (?, ?, ?, 'thread', ?, ?, ?, ?)`).run(id, String(input.feature.project_id), featureId, thread.name, thread.description, now, now)
|
||||
ids.set(thread.key, id); existing.set(name, id); created++
|
||||
}
|
||||
this.assertCurrent([featureId, ...output.moves.flatMap(item => ids.get(item.thread) ?? [])])
|
||||
const move = this.db.prepare('UPDATE supervision_event_stories SET story_id = ? WHERE event_id = ? AND story_id = ? AND is_primary = 1 AND user_set = 0')
|
||||
for (const item of output.moves) {
|
||||
const id = ids.get(item.thread)
|
||||
@@ -252,6 +253,14 @@ export class SupervisionStoryStore {
|
||||
.map(row => `${row.parent}|${nameKey(String(row.name))}`))
|
||||
}
|
||||
|
||||
/** Check model-selected destinations under the caller's write transaction. */
|
||||
private assertCurrent(ids: string[]): void {
|
||||
const unique = [...new Set(ids)]
|
||||
const count = this.db.prepare(`SELECT COUNT(*) AS n FROM supervision_stories
|
||||
WHERE id IN (SELECT value FROM json_each(?)) AND status = 'current'`).get(JSON.stringify(unique))!.n
|
||||
if (Number(count) !== unique.length) throw new Error('Story changed while organizing; retry with current stories')
|
||||
}
|
||||
|
||||
/** Applies one validated model answer for one project chunk in a single transaction. */
|
||||
apply(projectId: string, events: PendingEvent[], candidates: Row[], output: z.infer<typeof outputSchema>, options: Pick<StoryAssignmentOptions, 'crossProject' | 'threadEvents'>, others: Row[] = []): Omit<StoryAssignmentResult, 'calls' | 'split'> {
|
||||
const eventRefs = new Map(events.map((event, index) => [`e_${index + 1}`, event]))
|
||||
@@ -336,6 +345,12 @@ export class SupervisionStoryStore {
|
||||
ids.set(story.key, id)
|
||||
created++
|
||||
}
|
||||
const selected = [
|
||||
...output.assignments.flatMap(item => [item.story, ...(item.related ?? [])]),
|
||||
...output.stories.map(item => item.parent), ...output.concluded.map(item => item.story),
|
||||
...output.links.flatMap(item => [item.cross, item.feature])
|
||||
]
|
||||
this.assertCurrent(selected.flatMap(ref => ref ? ids.get(ref) ?? (otherRefs.has(ref) ? String(otherRefs.get(ref)!.id) : []) : []))
|
||||
const link = this.db.prepare('INSERT OR IGNORE INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, ?)')
|
||||
const mark = this.db.prepare('INSERT OR IGNORE INTO supervision_story_assigned (event_id) VALUES (?)')
|
||||
let assigned = 0, unassigned = 0
|
||||
@@ -374,12 +389,22 @@ export class SupervisionStoryStore {
|
||||
AND EXISTS (SELECT 1 FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id
|
||||
WHERE es.story_id = s.id AND e.project_id IN (SELECT value FROM json_each(?)))))
|
||||
ORDER BY s.level, s.name`).all(projects, projects, projects ?? '[]')
|
||||
const events = this.db.prepare(`SELECT es.event_id, es.is_primary, es.user_set, e.title, e.project_id,
|
||||
if (!rows.length) return []
|
||||
const events = this.db.prepare(`SELECT es.story_id, es.event_id, es.is_primary, es.user_set, e.title, e.project_id,
|
||||
COALESCE(e.started_at, e.occurred_at) AS started_at, COALESCE(e.ended_at, e.occurred_at) AS ended_at
|
||||
FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id WHERE es.story_id = ? AND e.superseded_by IS NULL ORDER BY started_at, e.id`)
|
||||
FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id
|
||||
WHERE es.story_id IN (SELECT value FROM json_each(?)) AND e.superseded_by IS NULL
|
||||
ORDER BY es.story_id, started_at, e.id`).all(JSON.stringify(rows.map(row => row.id)))
|
||||
const byStory = new Map<string, SupervisionStory['events']>()
|
||||
for (const e of events) {
|
||||
const id = String(e.story_id)
|
||||
const linked = byStory.get(id) ?? []
|
||||
linked.push({ id: String(e.event_id), title: String(e.title), projectId: e.project_id ? String(e.project_id) : null,
|
||||
startedAt: String(e.started_at), endedAt: String(e.ended_at), primary: e.is_primary === 1, userSet: e.user_set === 1 })
|
||||
byStory.set(id, linked)
|
||||
}
|
||||
return rows.map(row => {
|
||||
const linked = events.all(String(row.id)).map(e => ({ id: String(e.event_id), title: String(e.title), projectId: e.project_id ? String(e.project_id) : null,
|
||||
startedAt: String(e.started_at), endedAt: String(e.ended_at), primary: e.is_primary === 1, userSet: e.user_set === 1 }))
|
||||
const linked = byStory.get(String(row.id)) ?? []
|
||||
// A cross story's span comes from the events it links.
|
||||
const spanning = row.level === 'cross' ? linked : linked.filter(e => e.primary)
|
||||
return { id: String(row.id), projectId: row.project_id ? String(row.project_id) : null, projectName: row.project_name ? String(row.project_name) : null,
|
||||
|
||||
@@ -73,9 +73,9 @@ export function supersedeReextractedEvents(db: DatabaseSync, resultId: string):
|
||||
*/
|
||||
export function timelineCandidates(db: DatabaseSync, projectId: string, crossProject: boolean): Array<{ id: string; label: string; description: string }> {
|
||||
return db.prepare(`SELECT e.id, e.canonical_label AS label, e.description FROM supervision_entities e
|
||||
WHERE e.confirmation_state != 'revoked' AND (? = 1 OR EXISTS (
|
||||
SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id
|
||||
WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?))
|
||||
WHERE e.confirmation_state != 'revoked' AND (? = 1 OR e.id IN (
|
||||
SELECT ee.entity_id FROM supervision_events ev JOIN supervision_event_entities ee ON ee.event_id = ev.id
|
||||
WHERE ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?))
|
||||
ORDER BY e.updated_at DESC, e.id LIMIT 100`).all(crossProject ? 1 : 0, projectId) as Array<{ id: string; label: string; description: string }>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
|
||||
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
import type { SupervisionSummaryOutput } from '../../shared/supervision-contracts'
|
||||
|
||||
let directory: string, workerPath: string
|
||||
const cleanups: Array<() => void> = []
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'supervision-ui-reads-'))
|
||||
workerPath = join(directory, 'worker.cjs')
|
||||
await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: workerPath,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||
}, 60_000)
|
||||
afterEach(() => { vi.restoreAllMocks(); for (const close of cleanups.splice(0)) close() })
|
||||
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10 }) })
|
||||
|
||||
const at = '2026-10-01T12:00:00.000Z'
|
||||
const request = { trigger: 'manual' as const, scope: { kind: 'global' as const }, timeRange: { from: at, to: at } }
|
||||
const empty: SupervisionSummaryOutput = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
|
||||
function fixture() {
|
||||
const path = join(directory, `${randomUUID()}.sqlite`)
|
||||
const db = new AssistantDatabase(path)
|
||||
db.initialize(directory)
|
||||
const sql = (db as unknown as { database: DatabaseSync }).database
|
||||
cleanups.push(() => db.close())
|
||||
const project = db.listProjects()[0]!
|
||||
const other = db.createProject({ name: 'Other', description: '', rootPath: directory })
|
||||
db.saveSupervisionResult({ request,
|
||||
evidence: Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, sourceType: 'conversation' as const, sourceId: 'conversation',
|
||||
title: `Source ${i}`, content: `Full source ${i} ${'body'.repeat(2000)}`, occurredAt: at,
|
||||
locator: { projectId: i < 3 ? project.id : other.id } })),
|
||||
output: { ...empty, events: Array.from({ length: 6 }, (_, i) => ({ title: `Event ${i}`, description: 'Description', occurredAt: at,
|
||||
eventType: 'decision' as const, sourceReferenceIds: [`s${i}`], entityIds: [] })) } })
|
||||
const result = db.listSupervisionResults()[0]!
|
||||
const events = sql.prepare('SELECT id, project_id FROM supervision_events ORDER BY title').all()
|
||||
for (const [i, event] of events.entries()) {
|
||||
sql.prepare(`INSERT INTO supervision_stories (id, project_id, level, name, created_at, updated_at)
|
||||
VALUES (?, ?, 'feature', ?, ?, ?)`).run(`story${i}`, String(event.project_id), `Story ${i}`, at, at)
|
||||
sql.prepare('INSERT INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, 1)').run(String(event.id), `story${i}`)
|
||||
sql.prepare('INSERT INTO supervision_experiences (id, statement, created_at, updated_at) VALUES (?, ?, ?, ?)').run(`experience${i}`, `Experience ${i}`, at, at)
|
||||
sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES (?, ?, 'formed')").run(`experience${i}`, String(event.id))
|
||||
}
|
||||
// A cross-project experience must retain all links, not only the selected project's links.
|
||||
sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES ('experience0', ?, 'applied')").run(String(events[3]!.id))
|
||||
return { db, sql, path, project, other, result, events }
|
||||
}
|
||||
|
||||
it('returns source headers only in both graph paths while retaining full source detail and source order', () => {
|
||||
const f = fixture()
|
||||
const expected = f.sql.prepare('SELECT id, title, occurred_at FROM supervision_sources ORDER BY rowid').all()
|
||||
for (const input of [{ resultId: f.result.id }, { storyLineId: f.result.storyLineId! }]) {
|
||||
expect(f.db.getSupervisionGraph(input).sources).toEqual(expected)
|
||||
}
|
||||
for (const source of expected) expect(f.db.getSupervisionSource(String(source.id))?.content).toBe(`Full source ${String(source.title).slice(-1)} ${'body'.repeat(2000)}`)
|
||||
})
|
||||
|
||||
it('reads story memberships and scoped experiences in a constant number of SQL statements', () => {
|
||||
const f = fixture()
|
||||
let queries = 0
|
||||
const prepare = f.sql.prepare.bind(f.sql)
|
||||
vi.spyOn(f.sql, 'prepare').mockImplementation(sql => {
|
||||
const statement = prepare(sql)
|
||||
const all = statement.all.bind(statement)
|
||||
vi.spyOn(statement, 'all').mockImplementation((...args) => { queries++; return all(...args) })
|
||||
return statement
|
||||
})
|
||||
const stories = f.db.supervisionStories().list({ kind: 'global' })
|
||||
expect(stories.map(story => [story.name, story.events.map(event => event.title)])).toEqual(
|
||||
Array.from({ length: 6 }, (_, i) => [`Story ${i}`, [`Event ${i}`]]))
|
||||
expect(queries).toBe(2)
|
||||
queries = 0
|
||||
const experiences = f.db.supervisionExperiences().list([f.other.id])
|
||||
expect(experiences.map(item => item.statement)).toEqual(['Experience 0', 'Experience 3', 'Experience 4', 'Experience 5'])
|
||||
expect(experiences[0]!.events.map(event => [event.title, event.role, event.storyName])).toEqual(
|
||||
[0, 3].sort((a, b) => String(f.events[a]!.id).localeCompare(String(f.events[b]!.id))).map(i => [`Event ${i}`, i ? 'applied' : 'formed', `Story ${i}`]))
|
||||
expect(queries).toBe(2)
|
||||
queries = 0
|
||||
expect(f.db.supervisionExperiences().list(['unrelated'])).toEqual([])
|
||||
expect(queries).toBe(1)
|
||||
})
|
||||
|
||||
it('serves UI reads on the real worker and observes writes committed before the next read', async () => {
|
||||
const f = fixture()
|
||||
const overview = f.db.listSupervisionResults()
|
||||
const graph = f.db.getSupervisionGraph({ resultId: f.result.id })
|
||||
const stories = { stories: f.db.supervisionStories().list(request.scope), experiences: f.db.supervisionExperiences().list(),
|
||||
unassigned: f.db.supervisionStories().unassignedCount(request.scope), canUndo: false }
|
||||
f.db.enableReadonlyWorker(workerPath)
|
||||
const syncOverview = vi.spyOn(f.db, 'listSupervisionResults').mockImplementation(() => { throw new Error('Main overview') })
|
||||
const syncGraph = vi.spyOn(f.db, 'getSupervisionGraph').mockImplementation(() => { throw new Error('Main graph') })
|
||||
const syncStories = vi.spyOn(f.db, 'supervisionStories').mockImplementation(() => { throw new Error('Main stories') })
|
||||
expect(await f.db.listSupervisionResultsAsync()).toEqual(overview)
|
||||
expect(await f.db.getSupervisionGraphAsync({ resultId: f.result.id })).toEqual(graph)
|
||||
expect(await f.db.getSupervisionStoriesAsync(request.scope)).toEqual(stories)
|
||||
f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('Committed', f.result.id)
|
||||
expect((await f.db.listSupervisionResultsAsync())[0]!.summary).toBe('Committed')
|
||||
await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id, storyLineId: 'wrong' })).rejects.toThrow('不匹配')
|
||||
await f.db.readonlyWorkerForTest!.terminateWorkerForTest()
|
||||
await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow('backing off')
|
||||
await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id })).rejects.toThrow('backing off')
|
||||
await expect(f.db.getSupervisionStoriesAsync(request.scope)).rejects.toThrow('backing off')
|
||||
expect(syncOverview).not.toHaveBeenCalled()
|
||||
expect(syncGraph).not.toHaveBeenCalled()
|
||||
expect(syncStories).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects missing production worker configuration instead of performing a synchronous UI read', async () => {
|
||||
const f = fixture()
|
||||
await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow('worker is not configured')
|
||||
await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id })).rejects.toThrow('worker is not configured')
|
||||
await expect(f.db.getSupervisionStoriesAsync(request.scope)).rejects.toThrow('worker is not configured')
|
||||
f.db.enableReadonlyWorker(join(directory, 'missing.cjs'))
|
||||
await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('keeps a multi-statement UI read on one WAL snapshot while another connection commits', () => {
|
||||
const f = fixture()
|
||||
const reader = new AssistantDatabase(f.path)
|
||||
reader.openReadOnly()
|
||||
cleanups.push(() => reader.close())
|
||||
reader.readSnapshot(() => {
|
||||
expect(reader.listSupervisionResults()[0]!.summary).toBe('Review')
|
||||
f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('New summary', f.result.id)
|
||||
f.sql.prepare("UPDATE supervision_stories SET name = 'Renamed' WHERE id = 'story0'").run()
|
||||
expect(reader.listSupervisionResults()[0]!.summary).toBe('Review')
|
||||
expect(reader.getSupervisionStories(request.scope).stories[0]!.name).toBe('Story 0')
|
||||
})
|
||||
expect(reader.listSupervisionResults()[0]!.summary).toBe('New summary')
|
||||
expect(reader.getSupervisionStories(request.scope).stories.some(story => story.name === 'Renamed')).toBe(true)
|
||||
})
|
||||
|
||||
it('upgrades schema 61 indexes without changing rows, rowids, source order or reclaiming free pages', () => {
|
||||
const f = fixture()
|
||||
const tables = f.sql.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()
|
||||
const contents = () => tables.map(row => [row.name, f.sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])
|
||||
const before = contents()
|
||||
const sources = f.db.getSupervisionGraph({ resultId: f.result.id }).sources
|
||||
f.sql.exec(`DROP INDEX IF EXISTS supervision_sources_result;
|
||||
DROP INDEX IF EXISTS supervision_results_created;
|
||||
CREATE TABLE disposable (body BLOB); INSERT INTO disposable VALUES (zeroblob(1048576)); DROP TABLE disposable;
|
||||
PRAGMA user_version = 61`)
|
||||
expect(f.sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
||||
f.db.close()
|
||||
expect(getPendingAssistantStorageUpgrade(f.path)).toEqual({ migrateNotes: false, reclaimSpace: false })
|
||||
const progress = vi.fn()
|
||||
upgradeAssistantStorage(f.path, progress)
|
||||
const reopened = new AssistantDatabase(f.path)
|
||||
reopened.openReadOnly()
|
||||
cleanups.push(() => reopened.close())
|
||||
const sql = (reopened as unknown as { database: DatabaseSync }).database
|
||||
expect(tables.map(row => [row.name, sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])).toEqual(before)
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(sql.prepare("SELECT name FROM sqlite_master WHERE name IN ('supervision_sources_result', 'supervision_results_created') ORDER BY name").all())
|
||||
.toEqual([{ name: 'supervision_results_created' }, { name: 'supervision_sources_result' }])
|
||||
expect(reopened.getSupervisionGraph({ resultId: f.result.id }).sources).toEqual(sources)
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
expect(sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
expect(progress.mock.calls.some(([value]) => value.stage === 'compacting' || value.stage === 'converting')).toBe(false)
|
||||
expect(getPendingAssistantStorageUpgrade(f.path)).toBeUndefined()
|
||||
upgradeAssistantStorage(f.path, progress)
|
||||
})
|
||||
|
||||
it('publishes one final snapshot for multiple leaves and rolls back results and checkpoints together', () => {
|
||||
const f = fixture()
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
const store = f.db.supervisionReviewStore()
|
||||
f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) },
|
||||
messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }])
|
||||
store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' })
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)!
|
||||
store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at,
|
||||
locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }],
|
||||
{ ...empty, entities: [{ id: 'entity', label: `Entity ${i}`, description: 'Original', sourceReferenceIds: ['source'] }] })
|
||||
}
|
||||
f.sql.exec(`CREATE TRIGGER snapshot_once BEFORE UPDATE OF graph_snapshot_json ON supervision_results
|
||||
WHEN OLD.graph_snapshot_json != '{"entities":[],"relations":[]}' BEGIN SELECT RAISE(ABORT, 'Repeated graph snapshot'); END;
|
||||
CREATE TRIGGER fail_checkpoints BEFORE INSERT ON review_checkpoints BEGIN SELECT RAISE(ABORT, 'Checkpoint failed'); END`)
|
||||
const publication = { request, runId, coverage: store.progress(runId), evidence: [], output: empty }
|
||||
expect(() => f.db.saveSupervisionResult(publication)).toThrow('Checkpoint failed')
|
||||
expect(f.db.listSupervisionResults()).toHaveLength(1)
|
||||
expect(f.sql.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)!.status).toBe('running')
|
||||
expect(f.sql.prepare('SELECT * FROM review_checkpoints').all()).toEqual([])
|
||||
f.sql.exec('DROP TRIGGER fail_checkpoints')
|
||||
f.db.saveSupervisionResult(publication)
|
||||
const latest = f.db.listSupervisionResults()[0]!
|
||||
const graph = f.db.getSupervisionGraph({ resultId: latest.id })
|
||||
expect(graph.entities).toHaveLength(2)
|
||||
expect(graph.sources).toHaveLength(2)
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(2)
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
it('retains first-leaf snapshot values and source references when persisted identities recur across leaves', () => {
|
||||
const f = fixture()
|
||||
f.db.saveSupervisionResult({ request, evidence: [], output: { ...empty,
|
||||
entities: ['A', 'B'].map(id => ({ id, label: id, description: 'Original', sourceReferenceIds: [] })),
|
||||
relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: 'Original', sourceReferenceIds: [] }] } })
|
||||
const candidates = f.db.listSupervisionCandidates(request)
|
||||
const a = candidates.find(item => item.label === 'A')!, b = candidates.find(item => item.label === 'B')!
|
||||
f.db.applySupervisionEntityAction({ entityId: a.id, action: 'confirm' })
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
const store = f.db.supervisionReviewStore()
|
||||
f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) },
|
||||
messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }])
|
||||
store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' })
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)!
|
||||
store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at,
|
||||
locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }], { ...empty,
|
||||
entities: [a, b].map(item => ({ id: item.label, persistedId: item.id, label: item.label, description: `Leaf ${i}`, sourceReferenceIds: ['source'] })),
|
||||
relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: `Leaf ${i}`, sourceReferenceIds: ['source'] }] })
|
||||
}
|
||||
f.db.saveSupervisionResult({ request, runId, candidates, coverage: store.progress(runId), evidence: [], output: empty })
|
||||
const result = f.db.listSupervisionResults()[0]!
|
||||
const graph = f.db.getSupervisionGraph({ resultId: result.id })
|
||||
const sourceId = (graph.sources as Array<{ id: string; title: string }>).find(source => source.title === 'Leaf 0')!.id
|
||||
expect(graph.entities).toEqual([a, b].sort((x, y) => x.id < y.id ? -1 : 1).map(item => expect.objectContaining({ id: item.id,
|
||||
description: item.id === a.id ? 'Original' : 'Leaf 0', confirmation_state: item.id === a.id ? 'confirmed' : 'automatic',
|
||||
source_reference_ids_json: JSON.stringify([sourceId]) })))
|
||||
expect(graph.relations).toEqual([expect.objectContaining({ reason: 'Leaf 0', source_reference_ids_json: JSON.stringify([sourceId]) })])
|
||||
expect(f.sql.prepare('SELECT description FROM supervision_entities WHERE id = ?').get(b.id)!.description).toBe('Leaf 1')
|
||||
})
|
||||
@@ -64,7 +64,8 @@ it('preserves every source, revision, Unicode length and checkpoint for global,
|
||||
const id = f.db.startSupervisionRun(state.request)
|
||||
await f.db.initializeSupervisionReview(id, state, new AbortController().signal)
|
||||
const expected = rows.filter(row => scope.kind === 'global' || row.project_id === f.project.id).map(row => ({
|
||||
source: row.source, revision: hash(row.context), project_id: row.project_id, conversation_id: row.conversation_id,
|
||||
source: row.source, revision: hash(row.context), project_id: row.project_id,
|
||||
conversation_id: row.type === 'task' ? f.conversations[0]!.id : row.conversation_id,
|
||||
sequence: row.sequence, length: Array.from(String(row.body)).length,
|
||||
initial_offset: !reanalyze && row.source === source.source ? 3 : 0,
|
||||
processed_offset: !reanalyze && row.source === source.source ? 3 : 0
|
||||
@@ -137,6 +138,28 @@ it('fails explicitly when the worker entry is missing instead of running the sca
|
||||
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(0)
|
||||
})
|
||||
|
||||
it('worker resume omits hard-deleted owners including knowledge references and tasks', async () => {
|
||||
const f = await fixture()
|
||||
const id = f.db.startSupervisionRun(f.state.request)
|
||||
await f.db.initializeSupervisionReview(id, f.state, new AbortController().signal)
|
||||
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
|
||||
await f.db.resumeSupervisionReview(id, new AbortController().signal)
|
||||
expect(f.db.supervisionReviewStore().progress(id)).toMatchObject({ sources: 210, remainingSources: 210, omittedSources: 212 })
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_sources WHERE run_id = ? AND conversation_id = ?').get(id, f.conversations[0]!.id)!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('drops conversation sources deleted while the worker copies its initialization manifest', async () => {
|
||||
const f = await fixture()
|
||||
const id = f.db.startSupervisionRun(f.state.request)
|
||||
f.sql.exec(`CREATE TRIGGER delete_during_manifest AFTER INSERT ON supervision_review_sources
|
||||
WHEN (SELECT COUNT(*) FROM supervision_review_sources) = 200 BEGIN
|
||||
DELETE FROM conversations WHERE id = '${f.conversations[0]!.id}';
|
||||
END`)
|
||||
await f.db.initializeSupervisionReview(id, f.state, new AbortController().signal)
|
||||
expect(f.db.supervisionReviewStore().progress(id)).toMatchObject({ sources: 210, omittedSources: 212, remainingSources: 210 })
|
||||
expect(() => f.db.getConversation(f.conversations[0]!.id)).toThrow('对话不存在')
|
||||
})
|
||||
|
||||
it('publishes full worker-selected coverage atomically and skips it on the next production review', async () => {
|
||||
const f = await fixture()
|
||||
const pool = new SupervisionModelPool()
|
||||
@@ -157,6 +180,76 @@ it('publishes full worker-selected coverage atomically and skips it on the next
|
||||
expect(calls).toBe(before)
|
||||
})
|
||||
|
||||
it.each([false, true])('production worker preserves batch candidate isolation with crossProject=%s', async crossProject => {
|
||||
const f = await fixture()
|
||||
const empty = { summary: 'Seed', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
const projects = f.db.listProjects()
|
||||
for (const project of projects) f.db.saveSupervisionResult({
|
||||
request: { ...f.state.request, scope: project.id === f.project.id ? { kind: 'global' } : { kind: 'projects', projectIds: [project.id] } },
|
||||
evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(), title: 'Seed', content: 'Seed',
|
||||
occurredAt: f.state.request.timeRange.from, locator: { projectId: project.id } }],
|
||||
output: { ...empty, entities: [{ id: 'entity', label: project.id, description: 'Owned knowledge', sourceReferenceIds: ['s'] }],
|
||||
events: [{ title: 'Seed', description: '', occurredAt: f.state.request.timeRange.from, eventType: 'discussion', entityIds: ['entity'], sourceReferenceIds: ['s'] }] }
|
||||
})
|
||||
f.sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events')
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanup.push(async () => pool.dispose())
|
||||
const seen = new Set<string>()
|
||||
const runtime = { async *run(input: { prompt: string }) {
|
||||
if (!input.prompt.includes('These inputs are navigation summaries')) {
|
||||
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as Array<{ locator: { projectId: string } }>
|
||||
const projectId = evidence[0]!.locator.projectId
|
||||
const candidates = JSON.parse(input.prompt.split('KNOWN ENTITIES:\n\n')[1]!.split('\n\nPREVIOUS SUMMARY')[0]!) as Array<{ label: string }>
|
||||
expect(candidates.map(item => item.label).sort()).toEqual((crossProject ? projects.map(p => p.id) : [projectId]).sort())
|
||||
seen.add(projectId)
|
||||
}
|
||||
yield { type: 'text', delta: JSON.stringify(empty) }
|
||||
yield { type: 'done' }
|
||||
}, async dispose() {} } as unknown as AgentRuntime
|
||||
const service = createProductionSupervisorService(f.db, async () => ({ supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject }) }), async () => runtime, pool)
|
||||
await expect(service.run(f.state.request)).resolves.toMatchObject({ status: 'completed', coverage: { sources: 422 } })
|
||||
expect([...seen].sort()).toEqual(projects.map(p => p.id).sort())
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_entities').get()!.n).toBe(2)
|
||||
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['paused', 'cancelled'] as const)('honors %s during the final production candidate read before publishing', async action => {
|
||||
const f = await fixture()
|
||||
const pool = new SupervisionModelPool()
|
||||
cleanup.push(async () => pool.dispose())
|
||||
const runtime = { async *run() {
|
||||
yield { type: 'text', delta: JSON.stringify({ summary: 'Saved leaf', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
||||
yield { type: 'done' }
|
||||
}, async dispose() {} } as unknown as AgentRuntime
|
||||
const service = createProductionSupervisorService(f.db, async () => ({}), async () => runtime, pool)
|
||||
const candidates = f.db.supervisionCandidates.bind(f.db)
|
||||
let stopped = false
|
||||
let cancellation: Promise<void> | undefined
|
||||
vi.spyOn(f.db, 'supervisionCandidates').mockImplementation(async (...args) => {
|
||||
const result = await candidates(...args)
|
||||
const runId = service.execution().runId!
|
||||
if (!stopped && f.db.supervisionReviewStore().progress(runId).phase === 'saving') {
|
||||
stopped = true
|
||||
if (action === 'paused') service.pause(runId)
|
||||
else cancellation = service.cancel(runId)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const result = await service.run(f.state.request)
|
||||
await cancellation
|
||||
expect(stopped).toBe(true)
|
||||
expect(result.status).toBe(action)
|
||||
expect(f.db.listSupervisionResults()).toEqual([])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
const saved = f.db.supervisionReviewStore().batches(result.runId!, 1000)
|
||||
expect(saved.length).toBeGreaterThan(0)
|
||||
if (action === 'paused') {
|
||||
await expect(service.resume(result.runId!)).resolves.toMatchObject({ status: 'completed' })
|
||||
expect(f.db.supervisionReviewStore().batches(result.runId!, 1000)).toEqual(saved)
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(422)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['crash', 'close'] as const)('settles writer %s before releasing pending work and can rebuild after reopening', async action => {
|
||||
const f = await fixture()
|
||||
const id = f.db.startSupervisionRun(f.state.request)
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../../shared/supervision-contracts'
|
||||
import { reviewScope, type ReviewBatch } from './review-checkpoint'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SupervisionReviewStore, ReviewConfiguration, ReviewState } from './supervision-review-store'
|
||||
import { ReviewSourcesOmitted, type SupervisionReviewStore, type ReviewConfiguration, type ReviewState } from './supervision-review-store'
|
||||
import type { SupervisionReviewExecution, SupervisionReviewProgress } from '../../shared/supervision-review-contracts'
|
||||
|
||||
export type SupervisorSummarizerRequest = {
|
||||
@@ -287,7 +287,7 @@ export class SupervisorService {
|
||||
const includedBatch = batch ? { ...batch, checkpoints: batch.checkpoints.filter((checkpoint) =>
|
||||
batch.evidence.some((source) => source.locator?.source === checkpoint.source &&
|
||||
source.locator?.end === checkpoint.offset && boundedEvidence.some((included) =>
|
||||
included.id === source.id && included.content === source.content))) } : undefined
|
||||
included.id === source.id && included.content === source.content))) } : undefined
|
||||
const result = { runId, request, evidence: boundedEvidence, output, candidates, batch: includedBatch }
|
||||
await this.store.save(result)
|
||||
return result
|
||||
@@ -361,19 +361,23 @@ export class SupervisorService {
|
||||
controller.signal.throwIfAborted()
|
||||
const context = !navigation && this.review!.context ? await this.review!.context(request, controller.signal) : undefined
|
||||
controller.signal.throwIfAborted()
|
||||
if (!navigation) {
|
||||
db.omitDeletedConversations(runId)
|
||||
if (!db.hasSources(runId, evidence)) throw new ReviewSourcesOmitted('Deleted conversation sources omitted')
|
||||
}
|
||||
activity.inFlight++
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = await this.summarizer.summarize({ request, evidence, candidates: navigation ? [] : candidates,
|
||||
signal: controller.signal, timeoutSeconds: config.timeoutSeconds,
|
||||
systemInstruction: systemInstruction + (navigation
|
||||
? '\nThese inputs are navigation summaries, not original evidence. Return summary, changeDigest and openItems. Omit events, entities, entityChanges and relations or return them as empty arrays. All original leaf results are retained separately.'
|
||||
: '\nRetain atomic decisions, constraints, corrections, conflicting statements and unresolved items with source references. Attribute assistant proposals as proposals. Source locators use Unicode code points; split task JSON is a source fragment, not a complete object.') +
|
||||
raw = await this.summarizer.summarize({ request, evidence, candidates: navigation ? [] : candidates,
|
||||
signal: controller.signal, timeoutSeconds: config.timeoutSeconds,
|
||||
systemInstruction: systemInstruction + (navigation
|
||||
? '\nThese inputs are navigation summaries, not original evidence. Return summary, changeDigest and openItems. Omit events, entities, entityChanges and relations or return them as empty arrays. All original leaf results are retained separately.'
|
||||
: '\nRetain atomic decisions, constraints, corrections, conflicting statements and unresolved items with source references. Attribute assistant proposals as proposals. Source locators use Unicode code points; split task JSON is a source fragment, not a complete object.') +
|
||||
'\nWrite concise navigation while retaining decisions, qualifications and unresolved items. Entity IDs use only ASCII letters, numbers, underscore or hyphen (1..120 characters).',
|
||||
previousSummary: navigation ? undefined : context ? context.summary : this.store.summary?.(request),
|
||||
outputContract: navigation ? navigationContract : entityContract(candidates),
|
||||
background: navigation ? [] : context ? context.background : this.store.background?.(request),
|
||||
authorizeTool: async name => { throw new Error(`Supervisor tools are disabled: ${name}`) } })
|
||||
previousSummary: navigation ? undefined : context ? context.summary : this.store.summary?.(request),
|
||||
outputContract: navigation ? navigationContract : entityContract(candidates),
|
||||
background: navigation ? [] : context ? context.background : this.store.background?.(request),
|
||||
authorizeTool: async name => { throw new Error(`Supervisor tools are disabled: ${name}`) } })
|
||||
} finally { activity.inFlight-- }
|
||||
controller.signal.throwIfAborted()
|
||||
try {
|
||||
@@ -385,114 +389,142 @@ export class SupervisorService {
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
if (controller.signal.aborted) return stopped()
|
||||
const groups = db.groups(runId, config.concurrency)
|
||||
if (!groups.length) break
|
||||
const results = await Promise.allSettled(groups.map(async group => {
|
||||
controller.signal.throwIfAborted()
|
||||
const candidates = await candidatesFor(group.projectId)
|
||||
// An unusable answer is retried on a smaller prefix of the same pending text; nothing is skipped,
|
||||
// the rest of the text stays pending for the next batch.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const scale = 2 ** attempt
|
||||
const sized = attempt ? { ...config, batchCharacters: Math.max(500, Math.floor(config.batchCharacters / scale)),
|
||||
batchMessages: Math.max(1, Math.ceil(config.batchMessages / scale)) } : config
|
||||
const evidence = db.chunk(runId, group.projectId, group.conversationId, sized)
|
||||
if (!evidence.length) throw new Error('Pending source produced no reviewable content')
|
||||
try {
|
||||
const output = await summarize(evidence, false, candidates)
|
||||
const omittedBefore = db.progress(runId).omittedSources ?? 0
|
||||
try {
|
||||
db.omitDeletedConversations(runId)
|
||||
if (db.progress(runId).remainingSources) db.setPhase(runId, 'extracting')
|
||||
for (;;) {
|
||||
if (controller.signal.aborted) return stopped()
|
||||
const groups = db.groups(runId, config.concurrency)
|
||||
if (!groups.length) break
|
||||
const results = await Promise.allSettled(groups.map(async group => {
|
||||
controller.signal.throwIfAborted()
|
||||
db.save(runId, group.projectId, group.conversationId, evidence, output)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!(error instanceof SupervisionOutputError) || attempt >= outputRetries || controller.signal.aborted) throw error
|
||||
const candidates = await candidatesFor(group.projectId)
|
||||
// An unusable answer is retried on a smaller prefix of the same pending text; nothing is skipped,
|
||||
// the rest of the text stays pending for the next batch.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const scale = 2 ** attempt
|
||||
const sized = attempt ? { ...config, batchCharacters: Math.max(500, Math.floor(config.batchCharacters / scale)),
|
||||
batchMessages: Math.max(1, Math.ceil(config.batchMessages / scale)) } : config
|
||||
const evidence = db.chunk(runId, group.projectId, group.conversationId, sized)
|
||||
if (!evidence.length) return
|
||||
try {
|
||||
const output = await summarize(evidence, false, candidates)
|
||||
controller.signal.throwIfAborted()
|
||||
db.save(runId, group.projectId, group.conversationId, evidence, output)
|
||||
return
|
||||
} catch (error) {
|
||||
db.omitDeletedConversations(runId)
|
||||
if (!db.hasSources(runId, evidence)) return
|
||||
if (!(error instanceof SupervisionOutputError) || attempt >= outputRetries || controller.signal.aborted) throw error
|
||||
}
|
||||
}
|
||||
}))
|
||||
if (controller.signal.aborted) return stopped()
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
if (db.omitDeletedConversations(runId)) continue
|
||||
const progress = db.progress(runId)
|
||||
if (progress.remainingSources) continue
|
||||
const checkOmissions = () => {
|
||||
db.omitDeletedConversations(runId)
|
||||
if ((db.progress(runId).omittedSources ?? 0) !== (progress.omittedSources ?? 0)) {
|
||||
throw new ReviewSourcesOmitted('Deleted conversation sources omitted; rebuild review navigation')
|
||||
}
|
||||
}
|
||||
}))
|
||||
if (controller.signal.aborted) return stopped()
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
const progress = db.progress(runId)
|
||||
if (!progress.batches) {
|
||||
this.store.noChange?.(runId)
|
||||
// Earlier events may still await assignment (an upgrade backlog or a failed attempt).
|
||||
await this.organizeStories(db, runId, request, config, controller.signal)
|
||||
return { runId, request, evidence: [], output: empty, status: 'no_change', coverage: db.progress(runId) }
|
||||
}
|
||||
type Card = { id: string; output: SupervisionSummaryOutput }
|
||||
db.setPhase(runId, 'summarizing')
|
||||
const merge = async (left: Card, right: Card): Promise<Card> => {
|
||||
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 }
|
||||
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 }))
|
||||
// A navigation merge has fixed inputs; an unusable answer is asked again once before the run fails.
|
||||
let output: SupervisionSummaryOutput
|
||||
try { output = await summarize(evidence, true) } catch (error) {
|
||||
if (!(error instanceof SupervisionOutputError) || controller.signal.aborted) throw error
|
||||
output = await summarize(evidence, true)
|
||||
}
|
||||
controller.signal.throwIfAborted()
|
||||
db.saveNavigation(runId, id, [left.id, right.id], output)
|
||||
return { id, output }
|
||||
}
|
||||
// Reduce conversations, then projects, then scope. Binary carry stacks are logarithmic;
|
||||
// singleton groups pass through, so the whole tree still uses exactly N-1 merges.
|
||||
type Stack = Array<Card | undefined>
|
||||
const conversationStack: Stack = [], projectStack: Stack = [], scopeStack: Stack = []
|
||||
const push = async (stack: Stack, input: Card) => {
|
||||
let card = input, depth = 0
|
||||
while (stack[depth]) {
|
||||
card = await merge(stack[depth]!, card)
|
||||
stack[depth++] = undefined
|
||||
}
|
||||
stack[depth] = card
|
||||
}
|
||||
const finish = async (stack: Stack) => {
|
||||
let root: Card | undefined
|
||||
for (const card of stack.reverse()) if (card) root = root ? await merge(root, card) : card
|
||||
stack.length = 0
|
||||
return root
|
||||
}
|
||||
let previousProject: string | undefined, previousConversation: string | undefined
|
||||
let first: ReturnType<SupervisionReviewStore['batches']>[number] | undefined
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const batches = db.batches(runId, 10, offset)
|
||||
if (!batches.length) break
|
||||
for (const batch of batches) {
|
||||
validateReferences(batch.output, batch.evidence)
|
||||
first ??= batch
|
||||
if (previousConversation !== undefined && (batch.conversationId !== previousConversation || batch.projectId !== previousProject)) {
|
||||
await push(projectStack, (await finish(conversationStack))!)
|
||||
if (!progress.batches) {
|
||||
this.store.noChange?.(runId)
|
||||
// Earlier events may still await assignment (an upgrade backlog or a failed attempt).
|
||||
await this.organizeStories(db, runId, request, config, controller.signal)
|
||||
return { runId, request, evidence: [], output: empty, status: 'no_change', coverage: db.progress(runId) }
|
||||
}
|
||||
if (previousProject !== undefined && batch.projectId !== previousProject) await push(scopeStack, (await finish(projectStack))!)
|
||||
await push(conversationStack, batch)
|
||||
previousProject = batch.projectId
|
||||
previousConversation = batch.conversationId
|
||||
type Card = { id: string; output: SupervisionSummaryOutput }
|
||||
db.setPhase(runId, 'summarizing')
|
||||
const merge = async (left: Card, right: Card): Promise<Card> => {
|
||||
checkOmissions()
|
||||
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 }
|
||||
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 }))
|
||||
// A navigation merge has fixed inputs; an unusable answer is asked again once before the run fails.
|
||||
let output: SupervisionSummaryOutput
|
||||
try { output = await summarize(evidence, true) } catch (error) {
|
||||
checkOmissions()
|
||||
if (!(error instanceof SupervisionOutputError) || controller.signal.aborted) throw error
|
||||
output = await summarize(evidence, true)
|
||||
}
|
||||
controller.signal.throwIfAborted()
|
||||
checkOmissions()
|
||||
db.saveNavigation(runId, id, [left.id, right.id], output)
|
||||
return { id, output }
|
||||
}
|
||||
// Reduce conversations, then projects, then scope. Binary carry stacks are logarithmic;
|
||||
// singleton groups pass through, so the whole tree still uses exactly N-1 merges.
|
||||
type Stack = Array<Card | undefined>
|
||||
const conversationStack: Stack = [], projectStack: Stack = [], scopeStack: Stack = []
|
||||
const push = async (stack: Stack, input: Card) => {
|
||||
let card = input, depth = 0
|
||||
while (stack[depth]) {
|
||||
card = await merge(stack[depth]!, card)
|
||||
stack[depth++] = undefined
|
||||
}
|
||||
stack[depth] = card
|
||||
}
|
||||
const finish = async (stack: Stack) => {
|
||||
let root: Card | undefined
|
||||
for (const card of stack.reverse()) if (card) root = root ? await merge(root, card) : card
|
||||
stack.length = 0
|
||||
return root
|
||||
}
|
||||
let previousProject: string | undefined, previousConversation: string | undefined
|
||||
let first: ReturnType<SupervisionReviewStore['batches']>[number] | undefined
|
||||
for (let offset = 0;; offset += 10) {
|
||||
checkOmissions()
|
||||
const batches = db.batches(runId, 10, offset)
|
||||
if (!batches.length) break
|
||||
for (const batch of batches) {
|
||||
validateReferences(batch.output, batch.evidence)
|
||||
first ??= batch
|
||||
if (previousConversation !== undefined && (batch.conversationId !== previousConversation || batch.projectId !== previousProject)) {
|
||||
await push(projectStack, (await finish(conversationStack))!)
|
||||
}
|
||||
if (previousProject !== undefined && batch.projectId !== previousProject) await push(scopeStack, (await finish(projectStack))!)
|
||||
await push(conversationStack, batch)
|
||||
previousProject = batch.projectId
|
||||
previousConversation = batch.conversationId
|
||||
}
|
||||
}
|
||||
await push(projectStack, (await finish(conversationStack))!)
|
||||
await push(scopeStack, (await finish(projectStack))!)
|
||||
const root = await finish(scopeStack)
|
||||
controller.signal.throwIfAborted()
|
||||
checkOmissions()
|
||||
db.assertComplete(runId)
|
||||
db.setPhase(runId, 'saving')
|
||||
// A resumed run reuses leaves saved earlier; offer their projects' candidates too.
|
||||
const savedProjects = new Set<string>()
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const saved = db.batches(runId, 10, offset)
|
||||
if (!saved.length) break
|
||||
for (const batch of saved) savedProjects.add(batch.projectId)
|
||||
}
|
||||
for (const projectId of savedProjects) await candidatesFor(projectId)
|
||||
controller.signal.throwIfAborted()
|
||||
checkOmissions()
|
||||
const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()],
|
||||
evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output,
|
||||
status: 'completed', coverage: { ...db.progress(runId), complete: true } }
|
||||
await this.store.save(result)
|
||||
await this.organizeStories(db, runId, request, config, controller.signal)
|
||||
return { ...result, coverage: { ...db.progress(runId), complete: true } }
|
||||
} catch (error) {
|
||||
db.omitDeletedConversations(runId)
|
||||
if (error instanceof ReviewSourcesOmitted || (db.progress(runId).omittedSources ?? 0) !== omittedBefore) continue
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await push(projectStack, (await finish(conversationStack))!)
|
||||
await push(scopeStack, (await finish(projectStack))!)
|
||||
const root = await finish(scopeStack)
|
||||
controller.signal.throwIfAborted()
|
||||
db.assertComplete(runId)
|
||||
db.setPhase(runId, 'saving')
|
||||
// A resumed run reuses leaves saved earlier; offer their projects' candidates too.
|
||||
const savedProjects = new Set<string>()
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const saved = db.batches(runId, 10, offset)
|
||||
if (!saved.length) break
|
||||
for (const batch of saved) savedProjects.add(batch.projectId)
|
||||
}
|
||||
for (const projectId of savedProjects) await candidatesFor(projectId)
|
||||
const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()],
|
||||
evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output,
|
||||
status: 'completed', coverage: { ...db.progress(runId), complete: true } }
|
||||
await this.store.save(result)
|
||||
await this.organizeStories(db, runId, request, config, controller.signal)
|
||||
return { ...result, coverage: { ...db.progress(runId), complete: true } }
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return stopped()
|
||||
this.store.fail?.(runId, error instanceof Error ? error.message : 'Review failed')
|
||||
|
||||
@@ -19,6 +19,14 @@ export interface ChannelDriver {
|
||||
stop(): void | Promise<void>
|
||||
}
|
||||
|
||||
export function isPermanentChannelError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'permanent' in error &&
|
||||
error.permanent === true
|
||||
)
|
||||
}
|
||||
|
||||
export interface DedupStore {
|
||||
claim(
|
||||
channel: string,
|
||||
@@ -83,6 +91,7 @@ export interface Outbox {
|
||||
enqueue(message: ChannelResultMessage): OutboxEntry | Promise<OutboxEntry>
|
||||
markDelivered(id: string): void | Promise<void>
|
||||
markFailed(id: string): void | Promise<void>
|
||||
markTerminal?(id: string): void | Promise<void>
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit?: number
|
||||
@@ -134,6 +143,16 @@ export class MemoryOutbox implements Outbox {
|
||||
}
|
||||
}
|
||||
|
||||
markTerminal(id: string): void {
|
||||
const entry = this.entries.get(id)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
entry.state = 'terminal'
|
||||
entry.attempts += 1
|
||||
entry.message = this.withoutAttachments(entry.message)
|
||||
}
|
||||
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit = this.maximumEntries
|
||||
@@ -146,6 +165,8 @@ export class MemoryOutbox implements Outbox {
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.state === 'terminal') -
|
||||
Number(right.state === 'terminal') ||
|
||||
left.attempts - right.attempts ||
|
||||
left.createdAt - right.createdAt
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ChannelDriver } from './channel-driver'
|
||||
import type { ChannelDriver, ChannelExecutor, ChannelInboundHandler } from './channel-driver'
|
||||
import type { ChannelRuntimeStatus, ChannelRuntimeStatusChange } from '../../shared/channel-settings-contracts'
|
||||
import {
|
||||
ChannelManager,
|
||||
type ManagedChannelService
|
||||
@@ -15,7 +16,11 @@ import {
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
const electronFetch = vi.hoisted(() => vi.fn<typeof globalThis.fetch>())
|
||||
vi.mock('electron', () => ({ net: { fetch: electronFetch } }))
|
||||
|
||||
afterEach(async () => {
|
||||
electronFetch.mockReset()
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
|
||||
)
|
||||
@@ -102,6 +107,375 @@ function managerHarness(
|
||||
}
|
||||
|
||||
describe('ChannelManager', () => {
|
||||
it('emits deduplicated status deltas for async transitions and ignores late callbacks', async () => {
|
||||
const settingsStore = await store()
|
||||
const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
|
||||
await settingsStore.apply({ telegram: {
|
||||
...input, secret: { action: 'replace', value: 'status-event-test-secret' }
|
||||
} })
|
||||
const changes: ChannelRuntimeStatusChange[] = []
|
||||
const callbacks: Array<(status: ChannelRuntimeStatus) => void> = []
|
||||
const manager = new ChannelManager(settingsStore, executor, {
|
||||
onStatusChanged: (change) => { changes.push(change) },
|
||||
createDriver: (settings, onStatus) => {
|
||||
if (onStatus) callbacks.push(onStatus)
|
||||
return {
|
||||
...inertDriver(settings.channel),
|
||||
testConnection: async () => ({ botId: '123' })
|
||||
}
|
||||
},
|
||||
createService: () => {
|
||||
const onStatus = callbacks.at(-1)!
|
||||
return {
|
||||
start: async () => { onStatus({ state: 'starting' }) },
|
||||
stop: async () => { onStatus({ state: 'stopped' }) }
|
||||
}
|
||||
}
|
||||
})
|
||||
await manager.initialize()
|
||||
expect(changes).toEqual([
|
||||
{ channel: 'weixin', status: { state: 'disabled' } },
|
||||
{ channel: 'wecom', status: { state: 'disabled' } },
|
||||
{ channel: 'dingtalk', status: { state: 'disabled' } },
|
||||
{ channel: 'telegram', status: { state: 'starting' } }
|
||||
])
|
||||
changes.length = 0
|
||||
callbacks[0]!({ state: 'running' })
|
||||
callbacks[0]!({ state: 'running' })
|
||||
callbacks[0]!({ state: 'error', lastError: 'status-event-test-secret disconnected' })
|
||||
callbacks[0]!({ state: 'error', lastError: 'status-event-test-secret disconnected' })
|
||||
callbacks[0]!({ state: 'error', lastError: 'polling conflict' })
|
||||
callbacks[0]!({ state: 'running' })
|
||||
expect(changes.map((change) => change.status.state)).toEqual(['running', 'error', 'error', 'running'])
|
||||
expect(changes.every((change) => change.channel === 'telegram')).toBe(true)
|
||||
expect(JSON.stringify(changes)).not.toContain('status-event-test-secret')
|
||||
expect(changes.at(-1)).toEqual({ channel: 'telegram', status: { state: 'running' } })
|
||||
changes.length = 0
|
||||
await manager.testConnection('telegram')
|
||||
expect(changes).toEqual([])
|
||||
await manager.reload('telegram')
|
||||
callbacks[0]!({ state: 'error', lastError: 'retired' })
|
||||
callbacks[1]!({ state: 'running' })
|
||||
expect(changes).toEqual([
|
||||
{ channel: 'telegram', status: { state: 'starting' } },
|
||||
{ channel: 'telegram', status: { state: 'running' } }
|
||||
])
|
||||
changes.length = 0
|
||||
await expect(manager.apply({ telegram: { ...input, secret: { action: 'clear' } } })).rejects.toThrow('Bot Token')
|
||||
expect((await settingsStore.resolve('telegram')).secret).toBe('status-event-test-secret')
|
||||
expect(changes).toEqual([])
|
||||
await manager.apply({ telegram: { ...input, enabled: false, secret: { action: 'clear' } } })
|
||||
callbacks[1]!({ state: 'running' })
|
||||
await manager.reload('telegram')
|
||||
await manager.stopAll()
|
||||
expect(changes).toEqual([{ channel: 'telegram', status: { state: 'disabled' } }])
|
||||
expect((await settingsStore.resolve('telegram')).secret).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves Telegram connection status to the driver during startup and delivery', async () => {
|
||||
const settingsStore = await store()
|
||||
await settingsStore.apply({ telegram: {
|
||||
enabled: true, secret: { action: 'replace', value: 'status-test-secret' },
|
||||
allowedSenderIds: [], allowGroupMessages: false
|
||||
} })
|
||||
let status!: (status: ChannelRuntimeStatus) => void
|
||||
let deliverySuccess!: () => void
|
||||
let deliveryFailure!: (error: Error) => void
|
||||
const manager = new ChannelManager(settingsStore, executor, {
|
||||
createDriver: (settings, onStatus) => {
|
||||
status = onStatus!
|
||||
return inertDriver(settings.channel)
|
||||
},
|
||||
createService: (_driver, _executor, options) => {
|
||||
deliverySuccess = () => options.onDeliverySuccess?.()
|
||||
deliveryFailure = (error) => options.onDeliveryFailure?.(error)
|
||||
return { start: async () => undefined, stop: async () => undefined }
|
||||
}
|
||||
})
|
||||
expect((await manager.initialize()).telegram.status).toEqual({ state: 'starting' })
|
||||
deliverySuccess()
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('starting')
|
||||
status({ state: 'error', lastError: 'Telegram polling conflict (409)' })
|
||||
deliverySuccess()
|
||||
deliveryFailure(new Error('send failed'))
|
||||
expect((await manager.snapshot()).telegram.status).toEqual({
|
||||
state: 'error', lastError: 'Telegram polling conflict (409)'
|
||||
})
|
||||
status({ state: 'running' })
|
||||
deliveryFailure(new Error('send failed'))
|
||||
expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running', lastError: 'Telegram sending failed: send failed' })
|
||||
status({ state: 'running' })
|
||||
expect((await manager.snapshot()).telegram.status.lastError).toBe('Telegram sending failed: send failed')
|
||||
deliveryFailure(new Error('status-test-secret token=synthetic-secret failed'))
|
||||
expect(JSON.stringify((await manager.snapshot()).telegram.status)).not.toMatch(/status-test-secret|synthetic-secret/)
|
||||
deliverySuccess()
|
||||
expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running' })
|
||||
await manager.stopAll()
|
||||
})
|
||||
|
||||
it('updates Telegram whitelists without cancelling accepted work or restarting the service', async () => {
|
||||
const settingsStore = await store()
|
||||
const input = { enabled: true, allowedSenderIds: ['123'], allowGroupMessages: false as const }
|
||||
await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'active-test-secret' } } })
|
||||
let inbound!: ChannelInboundHandler
|
||||
let activeSignal!: AbortSignal
|
||||
let finish!: () => void
|
||||
const work = new Promise<void>((resolve) => { finish = resolve })
|
||||
const execute = vi.fn<ChannelExecutor>(async (_message, signal) => {
|
||||
activeSignal = signal
|
||||
await work
|
||||
return { status: 'completed', output: 'finished' }
|
||||
})
|
||||
const updateAllowedSenderIds = vi.fn()
|
||||
const stop = vi.fn(async () => undefined)
|
||||
const send = vi.fn(async () => undefined)
|
||||
const createDriver = vi.fn((settings: ResolvedChannelSettings) => ({
|
||||
...inertDriver(settings.channel),
|
||||
start: async (handler: ChannelInboundHandler) => { inbound = handler },
|
||||
updateAllowedSenderIds, stop, send
|
||||
}))
|
||||
const manager = new ChannelManager(settingsStore, execute, { createDriver })
|
||||
await manager.initialize()
|
||||
const message = {
|
||||
channel: 'telegram', accountId: '456', eventId: '1', senderId: '123',
|
||||
conversationId: '456:123', conversationType: 'direct', text: 'hello'
|
||||
}
|
||||
await inbound(message, () => undefined)
|
||||
await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce())
|
||||
await manager.apply({ telegram: { ...input, allowedSenderIds: [], secret: { action: 'keep' } } })
|
||||
expect(updateAllowedSenderIds).toHaveBeenLastCalledWith([])
|
||||
expect(createDriver).toHaveBeenCalledOnce()
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
expect(activeSignal.aborted).toBe(false)
|
||||
await inbound({ ...message, eventId: '2' }, () => undefined)
|
||||
finish()
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalled())
|
||||
expect(execute).toHaveBeenCalledOnce()
|
||||
await manager.apply({ telegram: { ...input, allowedSenderIds: ['789'], secret: { action: 'keep' } } })
|
||||
await inbound({ ...message, eventId: '3', senderId: '789', conversationId: '456:789' }, () => undefined)
|
||||
await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2))
|
||||
expect(createDriver).toHaveBeenCalledOnce()
|
||||
await manager.stopAll()
|
||||
})
|
||||
|
||||
it.each(['keep', 'replace'] as const)('restarts a conflicted Telegram poller when saving the same token with %s', async (action) => {
|
||||
const settingsStore = await store()
|
||||
const input = { enabled: true, allowedSenderIds: ['123'], allowGroupMessages: false as const }
|
||||
const secret = 'recovery-test-placeholder'
|
||||
await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: secret } } })
|
||||
let conflict = true
|
||||
let polls = 0
|
||||
electronFetch.mockImplementation(async (input, init) => {
|
||||
const method = String(input).split('/').at(-1)
|
||||
if (method === 'getMe') return Response.json({ ok: true, result: { id: 456, is_bot: true } })
|
||||
if (method === 'getWebhookInfo') return Response.json({ ok: true, result: { url: '' } })
|
||||
if (method === 'getUpdates') {
|
||||
polls++
|
||||
if (conflict) return Response.json({ ok: false }, { status: 409 })
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init!.signal!
|
||||
if (signal.aborted) reject(signal.reason)
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`)
|
||||
})
|
||||
const manager = new ChannelManager(settingsStore, executor)
|
||||
try {
|
||||
await manager.initialize()
|
||||
await vi.waitFor(async () => expect((await manager.snapshot()).telegram.status.state).toBe('error'))
|
||||
conflict = false
|
||||
expect((await manager.testConnection('telegram')).ok).toBe(true)
|
||||
await manager.apply({ telegram: { ...input, secret: action === 'keep' ? { action } : { action, value: secret } } })
|
||||
await vi.waitFor(async () => {
|
||||
expect(polls).toBe(2)
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('running')
|
||||
})
|
||||
} finally {
|
||||
await manager.stopAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('validates replacement tokens before saving or stopping the active Telegram service', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-manager-'))
|
||||
roots.push(root)
|
||||
const path = join(root, 'settings.json')
|
||||
const settingsStore = new ChannelSettingsStore(path, cipher(), {})
|
||||
const input = { enabled: true, allowedSenderIds: ['123'], allowGroupMessages: false as const }
|
||||
await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'working-test-secret' } } })
|
||||
const original = await readFile(path, 'utf8')
|
||||
const events: string[] = []
|
||||
const probes: Array<{ start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }> = []
|
||||
let activeStatus!: (status: ChannelRuntimeStatus) => void
|
||||
const updateAllowedSenderIds = vi.fn()
|
||||
const manager = new ChannelManager(settingsStore, executor, {
|
||||
createDriver: (settings, onStatus) => {
|
||||
const secret = settings.channel === 'telegram' ? settings.secret : ''
|
||||
if (onStatus) {
|
||||
activeStatus = onStatus
|
||||
return { ...inertDriver(settings.channel), updateAllowedSenderIds }
|
||||
}
|
||||
const driver = {
|
||||
...inertDriver(settings.channel), start: vi.fn(), stop: vi.fn(() => { events.push('probe-stop') }),
|
||||
testConnection: vi.fn(async () => {
|
||||
events.push('probe')
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('running')
|
||||
if (secret === 'invalid-test-secret') throw new Error('invalid-test-secret invalid token')
|
||||
return { botId: '456' }
|
||||
})
|
||||
}
|
||||
probes.push(driver)
|
||||
return driver
|
||||
},
|
||||
createService: () => ({
|
||||
start: async () => { events.push('service-start'); activeStatus({ state: 'running' }) },
|
||||
stop: async () => { events.push('service-stop') },
|
||||
updateAllowedSenderIds
|
||||
})
|
||||
})
|
||||
await manager.initialize()
|
||||
events.length = 0
|
||||
await expect(manager.apply({ telegram: {
|
||||
...input, secret: { action: 'replace', value: 'invalid-test-secret' }
|
||||
} })).rejects.not.toThrow('invalid-test-secret')
|
||||
expect(events).toEqual(['probe', 'probe-stop'])
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
expect((await settingsStore.resolve('telegram')).secret).toBe('working-test-secret')
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('running')
|
||||
events.length = 0
|
||||
await manager.apply({ telegram: { ...input, secret: { action: 'replace', value: 'valid-test-secret' } } })
|
||||
expect(events).toEqual(['probe', 'probe-stop', 'service-stop', 'service-start'])
|
||||
expect((await settingsStore.resolve('telegram')).secret).toBe('valid-test-secret')
|
||||
for (const probe of probes) {
|
||||
expect(probe.start).not.toHaveBeenCalled()
|
||||
expect(probe.stop).toHaveBeenCalledOnce()
|
||||
}
|
||||
expect(updateAllowedSenderIds).not.toHaveBeenCalled()
|
||||
await manager.stopAll()
|
||||
})
|
||||
|
||||
it('uses Electron fetch in the default Telegram factory and only probes identity and webhook', async () => {
|
||||
electronFetch.mockImplementation(async (input) => {
|
||||
const method = String(input).split('/').at(-1)
|
||||
if (method === 'getMe') {
|
||||
return new Response(JSON.stringify({ ok: true, result: { id: 123, is_bot: true, username: 'example_bot' } }))
|
||||
}
|
||||
if (method === 'getWebhookInfo') {
|
||||
return new Response(JSON.stringify({ ok: true, result: { url: '' } }))
|
||||
}
|
||||
throw new Error(`Unexpected Telegram method: ${method}`)
|
||||
})
|
||||
const manager = new ChannelManager(await store(), executor)
|
||||
expect(await manager.testConnection('telegram', {
|
||||
enabled: false, secret: { action: 'replace', value: 'test-only-secret' },
|
||||
allowedSenderIds: [], allowGroupMessages: false
|
||||
})).toEqual({ channel: 'telegram', ok: true, botUsername: 'example_bot' })
|
||||
expect(electronFetch.mock.calls.map(([input]) => String(input).split('/').at(-1)))
|
||||
.toEqual(['getMe', 'getWebhookInfo'])
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('disabled')
|
||||
})
|
||||
it('tests Telegram identity without creating a second poller or changing active settings', async () => {
|
||||
const settingsStore = await store()
|
||||
const starts = vi.fn(async () => undefined)
|
||||
const stops = vi.fn(async () => undefined)
|
||||
const probe = vi.fn(async () => ({ botId: '123', botUsername: 'example_bot' }))
|
||||
const createService = vi.fn(() => ({ start: starts, stop: stops }))
|
||||
const testDrivers: ChannelDriver[] = []
|
||||
const manager = new ChannelManager(settingsStore, executor, {
|
||||
createDriver: (settings, onStatus) => {
|
||||
onStatus?.({ state: 'running' })
|
||||
const driver = { ...inertDriver(settings.channel), start: vi.fn(), stop: vi.fn(), testConnection: probe }
|
||||
testDrivers.push(driver)
|
||||
return driver
|
||||
},
|
||||
createService
|
||||
})
|
||||
const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
|
||||
await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'stored-test-secret' } } })
|
||||
await manager.initialize()
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('running')
|
||||
expect(await manager.testConnection('telegram', {
|
||||
...input, secret: { action: 'replace', value: 'temporary-test-secret' }
|
||||
})).toEqual({ channel: 'telegram', ok: true, botUsername: 'example_bot' })
|
||||
expect(await manager.test('telegram')).toEqual({ channel: 'telegram', ok: true, botUsername: 'example_bot' })
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
expect(createService).toHaveBeenCalledOnce()
|
||||
expect(starts).toHaveBeenCalledOnce()
|
||||
expect(stops).not.toHaveBeenCalled()
|
||||
for (const driver of testDrivers.slice(1)) {
|
||||
expect(driver.start).not.toHaveBeenCalled()
|
||||
expect(driver.stop).toHaveBeenCalledOnce()
|
||||
}
|
||||
expect((await settingsStore.resolve('telegram')).secret).toBe('stored-test-secret')
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('running')
|
||||
probe.mockRejectedValueOnce(new Error('temporary-test-secret failed'))
|
||||
const failure = await manager.test('telegram', { ...input, secret: { action: 'replace', value: 'temporary-test-secret' } })
|
||||
expect(failure.ok).toBe(false)
|
||||
expect(failure.error).not.toContain('temporary-test-secret')
|
||||
await manager.stopAll()
|
||||
})
|
||||
|
||||
it('tracks Telegram runtime statuses and ignores retired driver and service callbacks', async () => {
|
||||
const settingsStore = await store()
|
||||
const callbacks: Array<(status: ChannelRuntimeStatus) => void> = []
|
||||
const deliveryCallbacks: Array<() => void> = []
|
||||
const lifecycle: string[] = []
|
||||
const manager = new ChannelManager(settingsStore, executor, {
|
||||
createDriver: (settings, onStatus) => {
|
||||
callbacks.push(onStatus!)
|
||||
return inertDriver(settings.channel)
|
||||
},
|
||||
createService: (_driver, _executor, options) => {
|
||||
const index = callbacks.length - 1
|
||||
deliveryCallbacks.push(() => options.onDeliverySuccess?.())
|
||||
return {
|
||||
start: async () => {
|
||||
lifecycle.push(`start-${index}`)
|
||||
callbacks[index]?.({ state: 'running' })
|
||||
},
|
||||
stop: async () => {
|
||||
lifecycle.push(`stop-${index}`)
|
||||
callbacks[index]?.({ state: 'error', lastError: 'retired' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
|
||||
await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'status-test-secret' } } })
|
||||
await manager.initialize()
|
||||
callbacks[0]?.({ state: 'error', lastError: 'status-test-secret network failure' })
|
||||
expect((await manager.snapshot()).telegram.status).toMatchObject({ state: 'error' })
|
||||
expect((await manager.snapshot()).telegram.status.lastError).not.toContain('status-test-secret')
|
||||
callbacks[0]?.({ state: 'running' })
|
||||
expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running' })
|
||||
await manager.apply({ telegram: { ...input, secret: { action: 'keep' }, allowedSenderIds: ['123'] } })
|
||||
expect(lifecycle).toEqual(['start-0', 'stop-0', 'start-1'])
|
||||
callbacks[0]?.({ state: 'error', lastError: 'late failure' })
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('running')
|
||||
await manager.apply({ telegram: { ...input, enabled: false, secret: { action: 'keep' } } })
|
||||
callbacks[1]?.({ state: 'running' })
|
||||
deliveryCallbacks[1]?.()
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('disabled')
|
||||
await manager.apply({ telegram: { ...input, secret: { action: 'keep' } } })
|
||||
await manager.stopAll()
|
||||
callbacks[2]?.({ state: 'running' })
|
||||
expect((await manager.snapshot()).telegram.status.state).toBe('stopped')
|
||||
})
|
||||
|
||||
it('rejects temporary Telegram environment overrides', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-manager-'))
|
||||
roots.push(root)
|
||||
const settingsStore = new ChannelSettingsStore(join(root, 'settings.json'), cipher(), {
|
||||
GOODBUDDY_TELEGRAM_BOT_TOKEN: 'environment-test-secret'
|
||||
})
|
||||
const createDriver = vi.fn(() => inertDriver('telegram'))
|
||||
const manager = new ChannelManager(settingsStore, executor, { createDriver })
|
||||
expect((await manager.testConnection('telegram', {
|
||||
enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
})).ok).toBe(false)
|
||||
expect(createDriver).not.toHaveBeenCalled()
|
||||
})
|
||||
it('applies settings and dynamically starts, replaces, and disables services', async () => {
|
||||
const settingsStore = await store()
|
||||
const { manager, services } = managerHarness(settingsStore)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import {
|
||||
CHANNEL_SETTINGS_LIMITS,
|
||||
channelConnectionTestResultSchema,
|
||||
channelSettingsApplySchema,
|
||||
dingTalkChannelSettingsInputSchema,
|
||||
telegramChannelSettingsInputSchema,
|
||||
weComChannelSettingsInputSchema,
|
||||
type ChannelConnectionTestResult,
|
||||
type ChannelRuntimeStatus,
|
||||
type ChannelRuntimeStatusChange,
|
||||
type ChannelSettingsApply,
|
||||
type ChannelSettingsSnapshot,
|
||||
type CredentialChannel,
|
||||
type DingTalkChannelSettingsInput,
|
||||
type ManagedChannel,
|
||||
type TelegramChannelSettingsInput,
|
||||
type WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import type {
|
||||
@@ -33,11 +37,17 @@ import type { WechatSidecarLauncher } from './wechat-sidecar-client'
|
||||
export type ManagedChannelService = Pick<
|
||||
ChannelService,
|
||||
'start' | 'stop'
|
||||
>
|
||||
> & { updateAllowedSenderIds?: (ids: readonly string[]) => void }
|
||||
|
||||
export type ChannelDriverFactory = (
|
||||
settings: ResolvedChannelSettings
|
||||
) => ChannelDriver | Promise<ChannelDriver>
|
||||
settings: ResolvedChannelSettings,
|
||||
onStatus?: (status: ChannelRuntimeStatus) => void
|
||||
) => TestableChannelDriver | Promise<TestableChannelDriver>
|
||||
|
||||
type TestableChannelDriver = ChannelDriver & {
|
||||
testConnection?: () => Promise<{ botId: string; botUsername?: string }>
|
||||
updateAllowedSenderIds?: (ids: readonly string[]) => void
|
||||
}
|
||||
|
||||
export type ChannelServiceFactory = (
|
||||
driver: ChannelDriver,
|
||||
@@ -53,6 +63,7 @@ export type ChannelServiceFactory = (
|
||||
) => ManagedChannelService | Promise<ManagedChannelService>
|
||||
|
||||
export type ChannelManagerOptions = {
|
||||
onStatusChanged?: (change: ChannelRuntimeStatusChange) => void
|
||||
createDriver?: ChannelDriverFactory
|
||||
createService?: ChannelServiceFactory
|
||||
launchWechatSidecar?: WechatSidecarLauncher
|
||||
@@ -61,6 +72,10 @@ export type ChannelManagerOptions = {
|
||||
}
|
||||
|
||||
type TestSettingsInput =
|
||||
| {
|
||||
channel: 'telegram'
|
||||
settings?: TelegramChannelSettingsInput
|
||||
}
|
||||
| {
|
||||
channel: 'wecom'
|
||||
settings?: WeComChannelSettingsInput
|
||||
@@ -70,10 +85,11 @@ type TestSettingsInput =
|
||||
settings?: DingTalkChannelSettingsInput
|
||||
}
|
||||
|
||||
function defaultDriverFactory(
|
||||
async function defaultDriverFactory(
|
||||
settings: ResolvedChannelSettings,
|
||||
launchWechatSidecar?: WechatSidecarLauncher
|
||||
): ChannelDriver {
|
||||
launchWechatSidecar?: WechatSidecarLauncher,
|
||||
onStatus?: (status: ChannelRuntimeStatus) => void
|
||||
): Promise<TestableChannelDriver> {
|
||||
if (settings.channel === 'weixin') {
|
||||
if (!launchWechatSidecar) {
|
||||
throw new Error('微信 Sidecar 启动器不可用')
|
||||
@@ -83,6 +99,18 @@ function defaultDriverFactory(
|
||||
if (settings.secret === undefined) {
|
||||
throw new Error('通道 Secret 尚未配置')
|
||||
}
|
||||
if (settings.channel === 'telegram') {
|
||||
const [{ TelegramChannelDriver }, { net }] = await Promise.all([
|
||||
import('./telegram-channel-driver'),
|
||||
import('electron')
|
||||
])
|
||||
return new TelegramChannelDriver({
|
||||
secret: settings.secret,
|
||||
allowedSenderIds: settings.allowedSenderIds,
|
||||
fetch: (input, init) => net.fetch(input instanceof URL ? input.href : input, init),
|
||||
onStatus
|
||||
})
|
||||
}
|
||||
return settings.channel === 'wecom'
|
||||
? new WeComChannelDriver({
|
||||
botId: settings.botId,
|
||||
@@ -136,6 +164,10 @@ function sanitizedManagerFailure(message: string): Error {
|
||||
}
|
||||
|
||||
function validateResolved(settings: ResolvedChannelSettings): void {
|
||||
if (settings.channel === 'telegram') {
|
||||
if (!settings.secret) throw new Error('Telegram 需要 Bot Token')
|
||||
return
|
||||
}
|
||||
if (settings.channel === 'weixin') {
|
||||
if (
|
||||
settings.accountId.length === 0 ||
|
||||
@@ -175,7 +207,13 @@ export class ChannelManager {
|
||||
private readonly createService: ChannelServiceFactory
|
||||
private readonly dedupStore?: ChannelServiceOptions['dedupStore']
|
||||
private readonly outbox?: ChannelServiceOptions['outbox']
|
||||
private readonly onStatusChanged?: ChannelManagerOptions['onStatusChanged']
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
private readonly serviceGenerations = new Map<ManagedChannel, symbol>()
|
||||
private activeTelegram?: {
|
||||
settings: Extract<ResolvedChannelSettings, { channel: 'telegram' }>
|
||||
driver: TestableChannelDriver
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly store: ChannelSettingsStore,
|
||||
@@ -184,11 +222,12 @@ export class ChannelManager {
|
||||
) {
|
||||
this.createDriver =
|
||||
options.createDriver ??
|
||||
((settings) =>
|
||||
defaultDriverFactory(settings, options.launchWechatSidecar))
|
||||
((settings, onStatus) =>
|
||||
defaultDriverFactory(settings, options.launchWechatSidecar, onStatus))
|
||||
this.createService = options.createService ?? defaultServiceFactory
|
||||
this.dedupStore = options.dedupStore
|
||||
this.outbox = options.outbox
|
||||
this.onStatusChanged = options.onStatusChanged
|
||||
}
|
||||
|
||||
snapshot(): Promise<ChannelSettingsSnapshot> {
|
||||
@@ -204,7 +243,7 @@ export class ChannelManager {
|
||||
const settings = await this.store.resolveAll()
|
||||
for (const channelSettings of settings) {
|
||||
if (!channelSettings.enabled) {
|
||||
this.statuses.set(channelSettings.channel, {
|
||||
this.setStatus(channelSettings.channel, {
|
||||
state: 'disabled'
|
||||
})
|
||||
continue
|
||||
@@ -221,11 +260,17 @@ export class ChannelManager {
|
||||
|
||||
apply(input: ChannelSettingsApply): Promise<ChannelSettingsSnapshot> {
|
||||
return this.enqueue(async () => {
|
||||
input = channelSettingsApplySchema.parse(input)
|
||||
if (input.telegram?.secret.action === 'replace') {
|
||||
const result = await this.test('telegram', input.telegram)
|
||||
if (!result.ok) throw sanitizedManagerFailure(result.error!)
|
||||
}
|
||||
await this.store.apply(input)
|
||||
const channels: ManagedChannel[] = [
|
||||
...(input.weixin === undefined ? [] : (['weixin'] as const)),
|
||||
...(input.wecom === undefined ? [] : (['wecom'] as const)),
|
||||
...(input.dingtalk === undefined ? [] : (['dingtalk'] as const))
|
||||
...(input.dingtalk === undefined ? [] : (['dingtalk'] as const)),
|
||||
...(input.telegram === undefined ? [] : (['telegram'] as const))
|
||||
]
|
||||
for (const channel of channels) {
|
||||
const settings = await this.store.resolve(channel)
|
||||
@@ -233,12 +278,27 @@ export class ChannelManager {
|
||||
await this.disableService(channel)
|
||||
continue
|
||||
}
|
||||
const active = this.activeTelegram
|
||||
const service = this.services.get(channel)
|
||||
if (settings.channel === 'telegram' && active &&
|
||||
active.settings.secret === settings.secret &&
|
||||
(this.statuses.get(channel)?.state === 'running' || this.statuses.get(channel)?.state === 'starting') &&
|
||||
active.driver.updateAllowedSenderIds && service?.updateAllowedSenderIds) {
|
||||
active.driver.updateAllowedSenderIds(settings.allowedSenderIds)
|
||||
service.updateAllowedSenderIds(settings.allowedSenderIds)
|
||||
active.settings = settings
|
||||
continue
|
||||
}
|
||||
await this.replaceService(settings)
|
||||
}
|
||||
return this.snapshot()
|
||||
})
|
||||
}
|
||||
|
||||
test(
|
||||
channel: 'telegram',
|
||||
settings?: TelegramChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
test(
|
||||
channel: 'wecom',
|
||||
settings?: WeComChannelSettingsInput
|
||||
@@ -249,7 +309,7 @@ export class ChannelManager {
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
async test(
|
||||
channel: CredentialChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult> {
|
||||
let resolved: ResolvedChannelSettings | undefined
|
||||
try {
|
||||
@@ -258,7 +318,21 @@ export class ChannelManager {
|
||||
...(settings === undefined ? {} : { settings })
|
||||
} as TestSettingsInput)
|
||||
validateResolved(resolved)
|
||||
const service = await this.buildService(resolved)
|
||||
if (channel === 'telegram') {
|
||||
const driver = await this.createDriver(resolved)
|
||||
try {
|
||||
if (!driver.testConnection) throw new Error('Telegram connection test is unavailable')
|
||||
const identity = await driver.testConnection()
|
||||
return channelConnectionTestResultSchema.parse({
|
||||
channel,
|
||||
ok: true,
|
||||
...(identity.botUsername ? { botUsername: identity.botUsername } : {})
|
||||
})
|
||||
} finally {
|
||||
await Promise.resolve(driver.stop()).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
const { service } = await this.buildService(resolved)
|
||||
try {
|
||||
await service.start()
|
||||
} finally {
|
||||
@@ -284,6 +358,10 @@ export class ChannelManager {
|
||||
}
|
||||
}
|
||||
|
||||
testConnection(
|
||||
channel: 'telegram',
|
||||
settings?: TelegramChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
testConnection(
|
||||
channel: 'wecom',
|
||||
settings?: WeComChannelSettingsInput
|
||||
@@ -294,8 +372,11 @@ export class ChannelManager {
|
||||
): Promise<ChannelConnectionTestResult>
|
||||
testConnection(
|
||||
channel: CredentialChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
|
||||
): Promise<ChannelConnectionTestResult> {
|
||||
if (channel === 'telegram') {
|
||||
return this.test(channel, settings as TelegramChannelSettingsInput | undefined)
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? this.test(
|
||||
channel,
|
||||
@@ -310,13 +391,15 @@ export class ChannelManager {
|
||||
stopAll(): Promise<void> {
|
||||
return this.enqueue(async () => {
|
||||
const active = [...this.services.entries()]
|
||||
this.serviceGenerations.clear()
|
||||
this.activeTelegram = undefined
|
||||
this.services.clear()
|
||||
const results = await Promise.allSettled(
|
||||
active.map(([, service]) => Promise.resolve(service.stop()))
|
||||
)
|
||||
const resolved = await this.store.resolveAll()
|
||||
for (const settings of resolved) {
|
||||
this.statuses.set(settings.channel, {
|
||||
this.setStatus(settings.channel, {
|
||||
state: settings.enabled ? 'stopped' : 'disabled'
|
||||
})
|
||||
}
|
||||
@@ -344,17 +427,24 @@ export class ChannelManager {
|
||||
): Promise<void> {
|
||||
const channel = settings.channel
|
||||
const previous = this.services.get(channel)
|
||||
this.statuses.set(channel, { state: 'starting' })
|
||||
const generation = Symbol(channel)
|
||||
this.serviceGenerations.set(channel, generation)
|
||||
this.setStatus(channel, { state: 'starting' })
|
||||
let replacement: ManagedChannelService | undefined
|
||||
let driver: TestableChannelDriver | undefined
|
||||
try {
|
||||
validateResolved(settings)
|
||||
replacement = await this.buildService(settings)
|
||||
const built = await this.buildService(settings, generation)
|
||||
replacement = built.service
|
||||
driver = built.driver
|
||||
if (previous !== undefined) {
|
||||
await previous.stop()
|
||||
this.services.delete(channel)
|
||||
}
|
||||
await replacement.start()
|
||||
} catch (error) {
|
||||
if (channel === 'telegram') this.activeTelegram = undefined
|
||||
this.serviceGenerations.delete(channel)
|
||||
await Promise.resolve(replacement?.stop()).catch(() => undefined)
|
||||
if (
|
||||
previous !== undefined &&
|
||||
@@ -368,7 +458,7 @@ export class ChannelManager {
|
||||
? settings.token
|
||||
: settings.secret
|
||||
])
|
||||
this.statuses.set(channel, {
|
||||
this.setStatus(channel, {
|
||||
state: 'error',
|
||||
lastError: redacted
|
||||
})
|
||||
@@ -376,29 +466,56 @@ export class ChannelManager {
|
||||
}
|
||||
|
||||
this.services.set(channel, replacement)
|
||||
this.statuses.set(channel, { state: 'running' })
|
||||
if (settings.channel === 'telegram') {
|
||||
this.activeTelegram = { settings, driver: driver! }
|
||||
} else if (this.statuses.get(channel)?.state === 'starting') {
|
||||
this.setStatus(channel, { state: 'running' })
|
||||
}
|
||||
}
|
||||
|
||||
private async disableService(channel: ManagedChannel): Promise<void> {
|
||||
if (channel === 'telegram') this.activeTelegram = undefined
|
||||
this.serviceGenerations.delete(channel)
|
||||
const previous = this.services.get(channel)
|
||||
if (previous !== undefined) {
|
||||
await previous.stop()
|
||||
this.services.delete(channel)
|
||||
}
|
||||
this.statuses.set(channel, { state: 'disabled' })
|
||||
this.setStatus(channel, { state: 'disabled' })
|
||||
}
|
||||
|
||||
private async buildService(
|
||||
settings: ResolvedChannelSettings
|
||||
): Promise<ManagedChannelService> {
|
||||
const driver = await this.createDriver(settings)
|
||||
return this.createService(driver, this.executor, {
|
||||
settings: ResolvedChannelSettings,
|
||||
generation?: symbol
|
||||
): Promise<{ service: ManagedChannelService; driver: TestableChannelDriver }> {
|
||||
let connectionStatus: ChannelRuntimeStatus = { state: 'starting' }
|
||||
let deliveryError: string | undefined
|
||||
const onStatus = (status: ChannelRuntimeStatus): void => {
|
||||
if (generation === undefined || this.serviceGenerations.get(settings.channel) !== generation) return
|
||||
connectionStatus = status
|
||||
const lastError = status.lastError ?? deliveryError
|
||||
this.setStatus(settings.channel, {
|
||||
state: status.state,
|
||||
...(lastError ? {
|
||||
lastError: redactManagerError(lastError, [
|
||||
settings.channel === 'weixin' ? settings.token : settings.secret
|
||||
])
|
||||
} : {})
|
||||
})
|
||||
}
|
||||
const driver = await this.createDriver(settings, onStatus)
|
||||
const service = await this.createService(driver, this.executor, {
|
||||
allowedSenderIds: settings.allowedSenderIds,
|
||||
allowGroupMessages: settings.allowGroupMessages,
|
||||
dedupStore: this.dedupStore,
|
||||
outbox: this.outbox,
|
||||
onDeliveryFailure: (error) => {
|
||||
this.statuses.set(settings.channel, {
|
||||
if (settings.channel === 'telegram') {
|
||||
deliveryError = `Telegram sending failed: ${redactManagerError(error, [settings.secret])}`
|
||||
onStatus(connectionStatus)
|
||||
return
|
||||
}
|
||||
onStatus({
|
||||
state: 'error',
|
||||
lastError: redactManagerError(error, [
|
||||
settings.channel === 'weixin'
|
||||
@@ -408,14 +525,32 @@ export class ChannelManager {
|
||||
})
|
||||
},
|
||||
onDeliverySuccess: () => {
|
||||
this.statuses.set(settings.channel, { state: 'running' })
|
||||
if (settings.channel === 'telegram') {
|
||||
deliveryError = undefined
|
||||
onStatus(connectionStatus)
|
||||
return
|
||||
}
|
||||
onStatus({ state: 'running' })
|
||||
}
|
||||
})
|
||||
return { service, driver }
|
||||
}
|
||||
|
||||
private async settingsForTest(
|
||||
input: TestSettingsInput
|
||||
): Promise<ResolvedChannelSettings> {
|
||||
if (input.channel === 'telegram') {
|
||||
const current = await this.store.resolve('telegram')
|
||||
if (input.settings === undefined) return current
|
||||
if (current.readOnly) throw new Error('环境变量通道配置为只读,不能使用临时设置')
|
||||
const parsed = telegramChannelSettingsInputSchema.parse(input.settings)
|
||||
return {
|
||||
channel: 'telegram',
|
||||
enabled: parsed.enabled,
|
||||
...this.testCommonSettings(current.secret, parsed),
|
||||
allowGroupMessages: false
|
||||
}
|
||||
}
|
||||
if (input.channel === 'wecom') {
|
||||
const current = await this.store.resolve('wecom')
|
||||
if (input.settings === undefined) {
|
||||
@@ -450,7 +585,7 @@ export class ChannelManager {
|
||||
|
||||
private testCommonSettings(
|
||||
currentSecret: string | undefined,
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
|
||||
): {
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
@@ -473,6 +608,13 @@ export class ChannelManager {
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(channel: ManagedChannel, status: ChannelRuntimeStatus): void {
|
||||
const previous = this.statuses.get(channel)
|
||||
if (previous?.state === status.state && previous.lastError === status.lastError) return
|
||||
this.statuses.set(channel, status)
|
||||
this.onStatusChanged?.({ channel, status: { ...status } })
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
let value!: T
|
||||
const run = async (): Promise<void> => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
channelInboundTextSchema,
|
||||
type ChannelInboundText,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
MemoryDedupStore,
|
||||
MemoryOutbox,
|
||||
isPermanentChannelError,
|
||||
type ChannelDriver,
|
||||
type ChannelInboundHandler
|
||||
} from './channel-driver'
|
||||
@@ -135,6 +136,280 @@ describe('channel contracts', () => {
|
||||
})
|
||||
|
||||
describe('ChannelService', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stops the active request without launching queued conversation requests', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
const executor = vi.fn((_message: ChannelInboundText, _signal: AbortSignal) => {
|
||||
void _message
|
||||
void _signal
|
||||
return new Promise<never>(() => undefined)
|
||||
})
|
||||
const service = new ChannelService(driver, executor, {
|
||||
allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await driver.emit(inbound())
|
||||
await driver.emit(inbound({ eventId: 'queued' }))
|
||||
await service.stop()
|
||||
expect(executor).toHaveBeenCalledOnce()
|
||||
expect(executor.mock.calls[0]?.[1].aborted).toBe(true)
|
||||
expect(driver.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('does not launch a request whose asynchronous event claim finishes after stop', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
let finishClaim!: (claimed: boolean) => void
|
||||
const release = vi.fn()
|
||||
const executor = vi.fn()
|
||||
const service = new ChannelService(driver, executor, {
|
||||
allowedSenderIds: ['allowed-user'],
|
||||
dedupStore: {
|
||||
claim: () => new Promise<boolean>((resolve) => { finishClaim = resolve }),
|
||||
release
|
||||
}
|
||||
})
|
||||
await service.start()
|
||||
await driver.emit(inbound())
|
||||
const stopped = service.stop()
|
||||
finishClaim(true)
|
||||
await stopped
|
||||
expect(executor).not.toHaveBeenCalled()
|
||||
expect(release).toHaveBeenCalledWith('fake', 'default', 'event-1')
|
||||
})
|
||||
|
||||
it('updates and validates the whitelist without cancelling active work', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
let finish!: (result: { status: string }) => void
|
||||
const executor = vi.fn((_message: ChannelInboundText, _signal: AbortSignal) => {
|
||||
void _message
|
||||
void _signal
|
||||
return new Promise<{ status: string }>((resolve) => { finish = resolve })
|
||||
})
|
||||
const service = new ChannelService(driver, executor, {
|
||||
allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await driver.emit(inbound())
|
||||
await driver.emit(inbound({ eventId: 'queued' }))
|
||||
service.updateAllowedSenderIds([' new-user '])
|
||||
expect(() => service.updateAllowedSenderIds([' '])).toThrow('白名单')
|
||||
expect(executor.mock.calls[0]?.[1].aborted).toBe(false)
|
||||
finish({ status: 'completed' })
|
||||
await waitForSent(driver, 1)
|
||||
await driver.emit(inbound({ senderId: 'new-user', eventId: 'new-user' }))
|
||||
await vi.waitFor(() => expect(executor).toHaveBeenCalledTimes(2))
|
||||
finish({ status: 'completed' })
|
||||
await waitForSent(driver, 2)
|
||||
expect(driver.sent.map((message) => message.eventId)).toEqual(['event-1', 'new-user'])
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('starts without waiting for a blocked retry, then cancels and awaits it on stop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
outbox.enqueue({ ...inbound(), status: 'completed', recipientId: 'allowed-user' })
|
||||
let finish!: () => void
|
||||
let retrySignal!: AbortSignal
|
||||
const send = vi.spyOn(driver, 'send').mockImplementation((_message, signal) => {
|
||||
retrySignal = signal
|
||||
return new Promise<void>((resolve) => { finish = resolve })
|
||||
})
|
||||
const service = new ChannelService(driver, vi.fn(), { outbox })
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
let stopped = false
|
||||
const stop = service.stop().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(retrySignal.aborted).toBe(true)
|
||||
expect(stopped).toBe(false)
|
||||
finish()
|
||||
await stop
|
||||
expect(stopped).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('retries failed runtime results on recovery and does not poll an empty outbox', async () => {
|
||||
vi.useFakeTimers()
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
const list = vi.spyOn(outbox, 'listUndelivered')
|
||||
const send = vi.spyOn(driver, 'send').mockRejectedValueOnce(new Error('offline'))
|
||||
const executor = vi.fn(async () => ({ status: 'failed', error: 'runtime failed' }))
|
||||
const service = new ChannelService(driver, executor, {
|
||||
outbox, allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const idleReads = list.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(list).toHaveBeenCalledTimes(idleReads)
|
||||
await driver.emit(inbound())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
expect(driver.sent[0]?.status).toBe('failed')
|
||||
expect(outbox.listUndelivered()).toEqual([])
|
||||
expect(executor).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('backs off and stops retrying after five failed sends', async () => {
|
||||
vi.useFakeTimers()
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
const send = vi.spyOn(driver, 'send').mockRejectedValue(new Error('offline'))
|
||||
const service = new ChannelService(driver, async () => ({ status: 'completed' }), {
|
||||
outbox, allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await driver.emit(inbound())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
for (const [index, delay] of [1_000, 2_000, 4_000, 8_000].entries()) {
|
||||
await vi.advanceTimersByTimeAsync(delay - 1)
|
||||
expect(send).toHaveBeenCalledTimes(index + 1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(send).toHaveBeenCalledTimes(index + 2)
|
||||
}
|
||||
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 5 })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(send).toHaveBeenCalledTimes(5)
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('bounds recovery passes and backs off after three consecutive failures', async () => {
|
||||
vi.useFakeTimers()
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
outbox.enqueue({
|
||||
channel: 'fake', eventId: `saved-${index}`, conversationId: 'conversation-1',
|
||||
recipientId: 'allowed-user', status: 'completed'
|
||||
})
|
||||
}
|
||||
const send = vi.spyOn(driver, 'send').mockRejectedValue(new Error('offline'))
|
||||
const service = new ChannelService(driver, vi.fn(), { outbox })
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(send).toHaveBeenCalledTimes(3)
|
||||
send.mockResolvedValue(undefined)
|
||||
await vi.advanceTimersByTimeAsync(1_999)
|
||||
expect(send).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(send).toHaveBeenCalledTimes(103)
|
||||
expect(outbox.listUndelivered()).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(send).toHaveBeenCalledTimes(104)
|
||||
expect(outbox.listUndelivered()).toEqual([])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it('does not retry an in-flight live delivery and aborts its send on stop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const driver = new FakeChannelDriver()
|
||||
let liveSignal!: AbortSignal
|
||||
const send = vi.spyOn(driver, 'send').mockImplementation((message, signal) => {
|
||||
if (message.eventId === 'held') {
|
||||
liveSignal = signal
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('offline'))
|
||||
})
|
||||
const service = new ChannelService(driver, async () => ({ status: 'completed' }), {
|
||||
allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await driver.emit(inbound({ eventId: 'held' }))
|
||||
await driver.emit(inbound({ eventId: 'failed', conversationId: 'another' }))
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(send.mock.calls.filter(([message]) => message.eventId === 'held')).toHaveLength(1)
|
||||
expect(send.mock.calls.filter(([message]) => message.eventId === 'failed')).toHaveLength(2)
|
||||
await service.stop()
|
||||
expect(liveSignal.aborted).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('excludes live deliveries from a retry snapshot even if they finish before the retry reaches them', async () => {
|
||||
vi.useFakeTimers()
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
let finishLive!: () => void
|
||||
let finishRetry!: () => void
|
||||
const send = vi.spyOn(driver, 'send').mockImplementation((message) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (message.eventId === 'live') finishLive = resolve
|
||||
else finishRetry = resolve
|
||||
})
|
||||
)
|
||||
const service = new ChannelService(driver, async () => ({ status: 'completed' }), {
|
||||
outbox, allowedSenderIds: ['allowed-user']
|
||||
})
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
outbox.enqueue({ ...inbound({ eventId: 'retry' }), recipientId: 'allowed-user', status: 'completed' })
|
||||
await driver.emit(inbound({ eventId: 'live' }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// A failed delivery schedules the recovery pass through the public path.
|
||||
send.mockRejectedValueOnce(new Error('offline'))
|
||||
await driver.emit(inbound({ eventId: 'failed', conversationId: 'another' }))
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(send.mock.calls.map(([message]) => message.eventId)).toEqual(['live', 'failed', 'retry'])
|
||||
finishLive()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(outbox.listUndelivered().some((entry) => entry.message.eventId === 'live')).toBe(false)
|
||||
send.mockResolvedValue(undefined)
|
||||
finishRetry()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(send.mock.calls.filter(([message]) => message.eventId === 'live')).toHaveLength(1)
|
||||
expect(outbox.listUndelivered()).toEqual([])
|
||||
await service.stop()
|
||||
})
|
||||
|
||||
it.each(['field', 'getter'] as const)('terminates permanent %s errors without resending after restart', async (kind) => {
|
||||
vi.useFakeTimers()
|
||||
const error = kind === 'field'
|
||||
? Object.assign(new Error('forbidden'), { permanent: true })
|
||||
: new class extends Error { get permanent(): boolean { return true } }('forbidden')
|
||||
expect(isPermanentChannelError(error)).toBe(true)
|
||||
expect(isPermanentChannelError(new Error('offline'))).toBe(false)
|
||||
const driver = new FakeChannelDriver()
|
||||
const outbox = new MemoryOutbox()
|
||||
const send = vi.spyOn(driver, 'send').mockRejectedValue(error)
|
||||
const service = new ChannelService(driver, async () => ({
|
||||
status: 'completed',
|
||||
attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 2, kind: 'file', dataBase64: 'b2s=' }]
|
||||
}), { outbox, allowedSenderIds: ['allowed-user'] })
|
||||
await service.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await driver.emit(inbound())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 })
|
||||
expect(outbox.listUndelivered()[0]?.message.attachments).toBeUndefined()
|
||||
await service.stop()
|
||||
const restarted = new ChannelService(driver, vi.fn(), { outbox })
|
||||
await restarted.start()
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(send).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await restarted.stop()
|
||||
})
|
||||
|
||||
it('acknowledges after accepting input and denies all senders when no allowlist is configured', async () => {
|
||||
const driver = new FakeChannelDriver()
|
||||
const executor = vi.fn()
|
||||
@@ -462,11 +737,13 @@ describe('ChannelService', () => {
|
||||
await service.start()
|
||||
|
||||
expect(driver.sent).toEqual([])
|
||||
expect(deliveryFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('已达到重试上限')
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(deliveryFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('已达到重试上限')
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(await outbox.listUndelivered()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: entry.id,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
MemoryDedupStore,
|
||||
MemoryOutbox,
|
||||
isPermanentChannelError,
|
||||
type ChannelDriver,
|
||||
type ChannelExecutor,
|
||||
type DedupStore,
|
||||
@@ -64,7 +65,7 @@ export function redactChannelError(value: string): string {
|
||||
}
|
||||
|
||||
export class ChannelService {
|
||||
private readonly allowedSenderIds: ReadonlySet<string>
|
||||
private allowedSenderIds: ReadonlySet<string> = new Set()
|
||||
private readonly allowGroupMessages: boolean
|
||||
private readonly maximumConcurrency: number
|
||||
private readonly maximumInputLength: number
|
||||
@@ -76,6 +77,12 @@ export class ChannelService {
|
||||
private readonly conversationTails = new Map<string, Promise<void>>()
|
||||
private state: ServiceState = 'idle'
|
||||
private stopPromise?: Promise<void>
|
||||
private readonly lifetime = new AbortController()
|
||||
private readonly sending = new Set<string>()
|
||||
private retryTimer?: ReturnType<typeof setTimeout>
|
||||
private retryPromise?: Promise<void>
|
||||
private retryRequested = false
|
||||
private retryDelay = 1_000
|
||||
|
||||
constructor(
|
||||
private readonly driver: ChannelDriver,
|
||||
@@ -90,12 +97,7 @@ export class ChannelService {
|
||||
throw new Error('通道标识无效')
|
||||
}
|
||||
|
||||
this.allowedSenderIds = new Set(
|
||||
(options.allowedSenderIds ?? []).map((senderId) => senderId.trim())
|
||||
)
|
||||
if (this.allowedSenderIds.has('')) {
|
||||
throw new Error('通道白名单包含无效身份')
|
||||
}
|
||||
this.updateAllowedSenderIds(options.allowedSenderIds ?? [])
|
||||
this.allowGroupMessages = options.allowGroupMessages ?? false
|
||||
this.maximumConcurrency = boundedInteger(
|
||||
options.maximumConcurrency,
|
||||
@@ -115,6 +117,14 @@ export class ChannelService {
|
||||
this.onDeliverySuccess = options.onDeliverySuccess
|
||||
}
|
||||
|
||||
updateAllowedSenderIds(senderIds: readonly string[]): void {
|
||||
const allowed = new Set(senderIds.map((senderId) => senderId.trim()))
|
||||
if (allowed.has('')) {
|
||||
throw new Error('通道白名单包含无效身份')
|
||||
}
|
||||
this.allowedSenderIds = allowed
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.state === 'running') {
|
||||
return
|
||||
@@ -138,9 +148,11 @@ export class ChannelService {
|
||||
this.onDeliveryFailure?.(error)
|
||||
}
|
||||
})
|
||||
await this.retryUndelivered()
|
||||
this.scheduleRetry(0)
|
||||
} catch (error) {
|
||||
this.state = 'idle'
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
this.state = 'idle'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -168,6 +180,9 @@ export class ChannelService {
|
||||
}
|
||||
|
||||
this.state = 'stopped'
|
||||
this.lifetime.abort(new Error('通道服务已停止'))
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = undefined
|
||||
for (const controller of this.active.values()) {
|
||||
controller.abort(new Error('通道服务已停止'))
|
||||
}
|
||||
@@ -180,6 +195,7 @@ export class ChannelService {
|
||||
const driverStop = Promise.resolve().then(() => this.driver.stop())
|
||||
const results = await Promise.allSettled([
|
||||
driverStop,
|
||||
this.retryPromise,
|
||||
...this.conversationTails.values()
|
||||
])
|
||||
const driverResult = results[0]
|
||||
@@ -188,12 +204,52 @@ export class ChannelService {
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetry(delay = this.retryDelay): void {
|
||||
if (this.state !== 'running') {
|
||||
return
|
||||
}
|
||||
if (this.retryPromise) {
|
||||
this.retryRequested = true
|
||||
return
|
||||
}
|
||||
if (this.retryTimer) {
|
||||
return
|
||||
}
|
||||
const run = (): void => {
|
||||
this.retryTimer = undefined
|
||||
this.retryRequested = false
|
||||
this.retryPromise = this.retryUndelivered()
|
||||
.catch((error: unknown) => {
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
this.onDeliveryFailure?.(error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.retryPromise = undefined
|
||||
if (this.retryRequested) {
|
||||
this.scheduleRetry()
|
||||
} else {
|
||||
this.retryDelay = 1_000
|
||||
}
|
||||
})
|
||||
}
|
||||
if (delay === 0) {
|
||||
run()
|
||||
} else {
|
||||
this.retryTimer = setTimeout(run, delay)
|
||||
this.retryTimer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
private async retryUndelivered(): Promise<void> {
|
||||
const entries = await this.outbox.listUndelivered(
|
||||
// Live sends may finish while this snapshot is read or earlier retries await I/O.
|
||||
const liveDeliveries = new Set(this.sending)
|
||||
const entries = (await this.outbox.listUndelivered(
|
||||
this.driver.channel,
|
||||
100
|
||||
)
|
||||
)).filter((entry) => !liveDeliveries.has(entry.id) && !this.sending.has(entry.id))
|
||||
let consecutiveFailures = 0
|
||||
let failed = false
|
||||
for (const entry of entries) {
|
||||
if (this.state !== 'running') {
|
||||
return
|
||||
@@ -201,31 +257,49 @@ export class ChannelService {
|
||||
if (entry.state === 'terminal' || entry.attempts >= 5) {
|
||||
this.onDeliveryFailure?.(
|
||||
new Error(
|
||||
`通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
|
||||
entry.attempts >= 5
|
||||
? `通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
|
||||
: `通道结果无法投递,发件箱记录 ${entry.id} 已终止`
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (this.sending.has(entry.id)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await this.driver.send(
|
||||
entry.message,
|
||||
new AbortController().signal
|
||||
this.lifetime.signal
|
||||
)
|
||||
await this.outbox.markDelivered(entry.id)
|
||||
this.onDeliverySuccess?.()
|
||||
consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
await this.outbox.markFailed(entry.id)
|
||||
this.onDeliveryFailure?.(error)
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= 3) {
|
||||
if (this.lifetime.signal.aborted) {
|
||||
return
|
||||
}
|
||||
await this.recordDeliveryFailure(entry.id, error)
|
||||
failed = true
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= 3) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.state === 'running') {
|
||||
const remaining = await this.outbox.listUndelivered(this.driver.channel, 100)
|
||||
this.retryRequested ||= remaining.some((entry) =>
|
||||
entry.state !== 'terminal' && entry.attempts < 5 && !this.sending.has(entry.id)
|
||||
)
|
||||
this.retryDelay = failed ? Math.min(this.retryDelay * 2, 30_000) : 1_000
|
||||
}
|
||||
}
|
||||
|
||||
private async process(rawMessage: unknown): Promise<void> {
|
||||
if (this.state !== 'running') {
|
||||
return
|
||||
}
|
||||
const parsed = channelInboundTextSchema.safeParse(rawMessage)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
@@ -252,13 +326,16 @@ export class ChannelService {
|
||||
|
||||
let durableResult = false
|
||||
try {
|
||||
if (this.lifetime.signal.aborted || !this.allowedSenderIds.has(message.senderId)) {
|
||||
return
|
||||
}
|
||||
if (message.text.length > this.maximumInputLength) {
|
||||
durableResult = await this.tryDeliver(
|
||||
this.result(message, {
|
||||
status: 'rejected',
|
||||
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
|
||||
}),
|
||||
new AbortController().signal
|
||||
this.lifetime.signal
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -269,7 +346,7 @@ export class ChannelService {
|
||||
status: 'busy',
|
||||
error: '当前请求较多,请稍后重试'
|
||||
}),
|
||||
new AbortController().signal
|
||||
this.lifetime.signal
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -292,7 +369,7 @@ export class ChannelService {
|
||||
status: cancelled ? 'cancelled' : 'failed',
|
||||
error: cancelled ? '请求已取消' : '请求处理失败'
|
||||
}),
|
||||
new AbortController().signal
|
||||
this.lifetime.signal
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -302,7 +379,7 @@ export class ChannelService {
|
||||
status: 'cancelled',
|
||||
error: '请求已取消'
|
||||
}),
|
||||
new AbortController().signal
|
||||
this.lifetime.signal
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -382,7 +459,10 @@ export class ChannelService {
|
||||
)
|
||||
}
|
||||
void Promise.resolve()
|
||||
.then(() => this.executor(message, signal, reportProgress))
|
||||
.then(() => {
|
||||
signal.throwIfAborted()
|
||||
return this.executor(message, signal, reportProgress)
|
||||
})
|
||||
.then(
|
||||
(result) => finish(resolve, result),
|
||||
(error: unknown) => finish(reject, error)
|
||||
@@ -429,17 +509,35 @@ export class ChannelService {
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const entry = await this.outbox.enqueue(message)
|
||||
this.sending.add(entry.id)
|
||||
try {
|
||||
await this.driver.send(message, signal)
|
||||
const deliverySignal = AbortSignal.any([signal, this.lifetime.signal])
|
||||
deliverySignal.throwIfAborted()
|
||||
await this.driver.send(message, deliverySignal)
|
||||
await this.outbox.markDelivered(entry.id)
|
||||
this.onDeliverySuccess?.()
|
||||
} catch (error) {
|
||||
await this.outbox.markFailed(entry.id)
|
||||
this.onDeliveryFailure?.(error)
|
||||
if (!this.lifetime.signal.aborted) {
|
||||
await this.recordDeliveryFailure(entry.id, error)
|
||||
if (!isPermanentChannelError(error)) {
|
||||
this.scheduleRetry()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.sending.delete(entry.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private async recordDeliveryFailure(id: string, error: unknown): Promise<void> {
|
||||
if (isPermanentChannelError(error) && this.outbox.markTerminal) {
|
||||
await this.outbox.markTerminal(id)
|
||||
} else {
|
||||
await this.outbox.markFailed(id)
|
||||
}
|
||||
this.onDeliveryFailure?.(error)
|
||||
}
|
||||
|
||||
private async tryDeliver(
|
||||
message: ChannelResultMessage,
|
||||
signal: AbortSignal
|
||||
|
||||
@@ -40,6 +40,75 @@ function createCipher(available = true): ChannelCredentialCipher {
|
||||
}
|
||||
|
||||
describe('ChannelSettingsStore', () => {
|
||||
it('persists encrypted Telegram settings with empty whitelist and secret actions', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
||||
const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
|
||||
const snapshot = await store.apply({ telegram: {
|
||||
...input, secret: { action: 'replace', value: 'test-only-secret' }
|
||||
} })
|
||||
expect(snapshot.telegram).toMatchObject({ enabled: true, secretConfigured: true, source: 'encrypted' })
|
||||
expect(JSON.stringify(snapshot)).not.toContain('test-only-secret')
|
||||
expect(snapshot.telegram).not.toHaveProperty('botId')
|
||||
expect(await readFile(filePath, 'utf8')).not.toContain('test-only-secret')
|
||||
const reloaded = new ChannelSettingsStore(filePath, createCipher(), {})
|
||||
expect(await reloaded.resolve('telegram')).toEqual({
|
||||
channel: 'telegram', ...input, secret: 'test-only-secret', source: 'encrypted', readOnly: false
|
||||
})
|
||||
await reloaded.apply({ telegram: { ...input, secret: { action: 'keep' }, allowedSenderIds: ['123'] } })
|
||||
expect((await reloaded.resolve('telegram')).secret).toBe('test-only-secret')
|
||||
await reloaded.apply({ telegram: { ...input, secret: { action: 'replace', value: 'replacement-test-secret' } } })
|
||||
expect((await reloaded.resolve('telegram')).secret).toBe('replacement-test-secret')
|
||||
const unreadable = new ChannelSettingsStore(filePath, createCipher(false), {})
|
||||
expect((await unreadable.snapshot()).telegram).toMatchObject({ source: 'unreadable', secretConfigured: false })
|
||||
await reloaded.apply({ telegram: { ...input, enabled: false, secret: { action: 'clear' } } })
|
||||
expect((await reloaded.snapshot()).telegram).toMatchObject({ enabled: false, source: 'none', secretConfigured: false })
|
||||
await expect(reloaded.apply({ telegram: { ...input, secret: { action: 'keep' } } })).rejects.toThrow('Bot Token')
|
||||
})
|
||||
|
||||
it('loads existing version 3 settings with Telegram defaults without rewriting', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const original = JSON.stringify({
|
||||
version: 3,
|
||||
weixin: { enabled: false },
|
||||
wecom: { enabled: false, botId: '', allowedSenderIds: [], allowGroupMessages: false },
|
||||
dingtalk: { enabled: false, clientId: '', allowedSenderIds: [], allowGroupMessages: false }
|
||||
})
|
||||
await writeFile(filePath, original)
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
||||
expect((await store.snapshot()).telegram).toEqual({
|
||||
enabled: false, secretConfigured: false, source: 'none', readOnly: false,
|
||||
allowedSenderIds: [], allowGroupMessages: false, status: { state: 'disabled' }
|
||||
})
|
||||
expect(await readFile(filePath, 'utf8')).toBe(original)
|
||||
})
|
||||
|
||||
it('uses read-only Telegram environment credentials, including help-only startup', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const environment = { GOODBUDDY_TELEGRAM_BOT_TOKEN: 'environment-test-secret' }
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), environment)
|
||||
expect(await store.resolve('telegram')).toEqual({
|
||||
channel: 'telegram', enabled: true, secret: 'environment-test-secret',
|
||||
allowedSenderIds: [], allowGroupMessages: false, source: 'environment', readOnly: true
|
||||
})
|
||||
await expect(store.apply({ telegram: {
|
||||
enabled: false, secret: { action: 'clear' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
} })).rejects.toThrow('环境变量配置')
|
||||
const configured = new ChannelSettingsStore(filePath, createCipher(), {
|
||||
...environment, GOODBUDDY_TELEGRAM_ALLOWED_SENDERS: '123, 456,123', GOODBUDDY_TELEGRAM_ENABLED: 'false'
|
||||
})
|
||||
expect(await configured.resolve('telegram')).toMatchObject({ enabled: false, allowedSenderIds: ['123', '456'] })
|
||||
for (const invalid of [
|
||||
{ GOODBUDDY_TELEGRAM_ALLOWED_SENDERS: '@user' },
|
||||
{ GOODBUDDY_TELEGRAM_ALLOW_GROUPS: 'true' },
|
||||
{ GOODBUDDY_TELEGRAM_ENABLED: 'invalid' }
|
||||
]) {
|
||||
const invalidStore = new ChannelSettingsStore(filePath, createCipher(), { ...environment, ...invalid })
|
||||
expect((await invalidStore.snapshot()).telegram).toMatchObject({
|
||||
enabled: false, readOnly: true, allowGroupMessages: false, status: { state: 'error' }
|
||||
})
|
||||
}
|
||||
})
|
||||
it('encrypts secrets and supports keep, replace, and clear', async () => {
|
||||
const filePath = await settingsPath()
|
||||
const store = new ChannelSettingsStore(filePath, createCipher(), {})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
CHANNEL_SETTINGS_LIMITS,
|
||||
allowedSenderIdsSchema,
|
||||
telegramAllowedSenderIdsSchema,
|
||||
channelSettingsApplySchema,
|
||||
type ChannelRuntimeStatus,
|
||||
type ChannelSettingsApply,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type CredentialChannel,
|
||||
type DingTalkChannelSettingsInput,
|
||||
type ManagedChannel,
|
||||
type TelegramChannelSettingsInput,
|
||||
type WeComChannelSettingsInput
|
||||
} from '../../shared/channel-settings-contracts'
|
||||
import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts'
|
||||
@@ -103,7 +105,16 @@ const storedSettingsSchema = z
|
||||
})
|
||||
.strict(),
|
||||
wecom: legacyStoredSettingsSchema.shape.wecom,
|
||||
dingtalk: legacyStoredSettingsSchema.shape.dingtalk
|
||||
dingtalk: legacyStoredSettingsSchema.shape.dingtalk,
|
||||
telegram: z.object({
|
||||
...storedChannelFields,
|
||||
allowedSenderIds: telegramAllowedSenderIdsSchema,
|
||||
allowGroupMessages: z.literal(false)
|
||||
}).strict().default({
|
||||
enabled: false,
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
})
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -111,6 +122,7 @@ type StoredSettings = z.infer<typeof storedSettingsSchema>
|
||||
type StoredCredentialChannel =
|
||||
| StoredSettings['wecom']
|
||||
| StoredSettings['dingtalk']
|
||||
| StoredSettings['telegram']
|
||||
type StoredEncryptedCredential = z.infer<
|
||||
typeof encryptedCredentialSchema
|
||||
>
|
||||
@@ -120,7 +132,7 @@ class DeferredWeixinMigrationError extends Error {}
|
||||
const credentialPayloadSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
channel: z.enum(['weixin', 'wecom', 'dingtalk']),
|
||||
channel: z.enum(['weixin', 'wecom', 'dingtalk', 'telegram']),
|
||||
secret: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -167,9 +179,19 @@ type EnvironmentChannel = {
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: boolean
|
||||
warning?: SettingsWarning
|
||||
configurationError?: string
|
||||
}
|
||||
|
||||
export type ResolvedChannelSettings =
|
||||
| {
|
||||
channel: 'telegram'
|
||||
enabled: boolean
|
||||
secret?: string
|
||||
allowedSenderIds: readonly string[]
|
||||
allowGroupMessages: false
|
||||
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
||||
readOnly: boolean
|
||||
}
|
||||
| {
|
||||
channel: 'weixin'
|
||||
enabled: boolean
|
||||
@@ -205,6 +227,11 @@ export type ResolvedChannelSettings =
|
||||
|
||||
const defaultStoredSettings: StoredSettings = {
|
||||
version: 3,
|
||||
telegram: {
|
||||
enabled: false,
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false
|
||||
},
|
||||
weixin: {
|
||||
enabled: false
|
||||
},
|
||||
@@ -337,20 +364,23 @@ export class ChannelSettingsStore {
|
||||
) {
|
||||
this.environmentChannels = {
|
||||
wecom: this.readEnvironmentChannel('wecom'),
|
||||
dingtalk: this.readEnvironmentChannel('dingtalk')
|
||||
dingtalk: this.readEnvironmentChannel('dingtalk'),
|
||||
telegram: this.readEnvironmentChannel('telegram')
|
||||
}
|
||||
}
|
||||
|
||||
async snapshot(
|
||||
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
|
||||
): Promise<ChannelSettingsSnapshot> {
|
||||
const [weixin, wecom, dingtalk] = await Promise.all([
|
||||
const [weixin, wecom, dingtalk, telegram] = await Promise.all([
|
||||
this.resolve('weixin'),
|
||||
this.resolve('wecom'),
|
||||
this.resolve('dingtalk')
|
||||
this.resolve('dingtalk'),
|
||||
this.resolve('telegram')
|
||||
])
|
||||
const weComEnvironment = this.environmentChannel('wecom')
|
||||
const dingTalkEnvironment = this.environmentChannel('dingtalk')
|
||||
const telegramEnvironment = this.environmentChannel('telegram')
|
||||
const warnings = [
|
||||
...this.warnings,
|
||||
...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
|
||||
@@ -363,6 +393,17 @@ export class ChannelSettingsStore {
|
||||
) === index
|
||||
)
|
||||
return {
|
||||
telegram: {
|
||||
enabled: telegram.enabled,
|
||||
secretConfigured: telegram.secret !== undefined,
|
||||
source: telegram.source,
|
||||
readOnly: telegram.readOnly,
|
||||
allowedSenderIds: [...telegram.allowedSenderIds],
|
||||
allowGroupMessages: false,
|
||||
status: telegramEnvironment.configurationError
|
||||
? { state: 'error', lastError: telegramEnvironment.configurationError }
|
||||
: statuses.telegram ?? defaultStatus(telegram.enabled)
|
||||
},
|
||||
weixin: {
|
||||
enabled: weixin.enabled,
|
||||
bindingConfigured: weixin.token !== undefined,
|
||||
@@ -418,6 +459,9 @@ export class ChannelSettingsStore {
|
||||
return this.snapshot(statuses)
|
||||
}
|
||||
|
||||
resolve(channel: 'telegram'): Promise<Extract<ResolvedChannelSettings, {
|
||||
channel: 'telegram'
|
||||
}>>
|
||||
resolve(channel: 'wecom'): Promise<Extract<ResolvedChannelSettings, {
|
||||
channel: 'wecom'
|
||||
}>>
|
||||
@@ -463,6 +507,9 @@ export class ChannelSettingsStore {
|
||||
source: 'environment' as const,
|
||||
readOnly: true
|
||||
}
|
||||
if (channel === 'telegram') {
|
||||
return { channel, ...common, allowGroupMessages: false }
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? {
|
||||
channel,
|
||||
@@ -493,6 +540,9 @@ export class ChannelSettingsStore {
|
||||
: ('encrypted' as const),
|
||||
readOnly: false
|
||||
}
|
||||
if (channel === 'telegram') {
|
||||
return { channel, ...common, allowGroupMessages: false }
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? { channel, botId: settings.wecom.botId, ...common }
|
||||
: { channel, clientId: settings.dingtalk.clientId, ...common }
|
||||
@@ -501,12 +551,14 @@ export class ChannelSettingsStore {
|
||||
resolveAll(): Promise<readonly [
|
||||
Extract<ResolvedChannelSettings, { channel: 'weixin' }>,
|
||||
Extract<ResolvedChannelSettings, { channel: 'wecom' }>,
|
||||
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>
|
||||
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>,
|
||||
Extract<ResolvedChannelSettings, { channel: 'telegram' }>
|
||||
]> {
|
||||
return Promise.all([
|
||||
this.resolve('weixin'),
|
||||
this.resolve('wecom'),
|
||||
this.resolve('dingtalk')
|
||||
this.resolve('dingtalk'),
|
||||
this.resolve('telegram')
|
||||
])
|
||||
}
|
||||
|
||||
@@ -583,6 +635,12 @@ export class ChannelSettingsStore {
|
||||
if (input.weixin !== undefined) {
|
||||
current.weixin.enabled = input.weixin.enabled
|
||||
}
|
||||
if (input.telegram !== undefined) {
|
||||
if (this.environmentChannel('telegram').owned) {
|
||||
throw new Error('Telegram 由环境变量配置,不能在设置中修改')
|
||||
}
|
||||
current.telegram = this.updateStoredChannel('telegram', current.telegram, input.telegram)
|
||||
}
|
||||
if (input.wecom !== undefined) {
|
||||
if (this.environmentChannel('wecom').owned) {
|
||||
throw new Error('企业微信由环境变量配置,不能在设置中修改')
|
||||
@@ -609,6 +667,7 @@ export class ChannelSettingsStore {
|
||||
}
|
||||
this.validateEnabledCredentialChannel('wecom', current.wecom)
|
||||
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
|
||||
this.validateEnabledCredentialChannel('telegram', current.telegram)
|
||||
await this.persist(current)
|
||||
this.settings = current
|
||||
if (!this.temporarilyDisabledWeixin) {
|
||||
@@ -631,6 +690,11 @@ export class ChannelSettingsStore {
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
private updateStoredChannel(
|
||||
channel: 'telegram',
|
||||
current: StoredSettings['telegram'],
|
||||
input: TelegramChannelSettingsInput
|
||||
): StoredSettings['telegram']
|
||||
private updateStoredChannel(
|
||||
channel: 'wecom',
|
||||
current: StoredSettings['wecom'],
|
||||
@@ -644,7 +708,7 @@ export class ChannelSettingsStore {
|
||||
private updateStoredChannel(
|
||||
channel: CredentialChannel,
|
||||
current: StoredCredentialChannel,
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
input: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
|
||||
): StoredCredentialChannel {
|
||||
const credential =
|
||||
input.secret.action === 'keep'
|
||||
@@ -662,6 +726,9 @@ export class ChannelSettingsStore {
|
||||
allowedSenderIds,
|
||||
allowGroupMessages: input.allowGroupMessages
|
||||
}
|
||||
if (channel === 'telegram') {
|
||||
return { ...common, allowGroupMessages: false }
|
||||
}
|
||||
return channel === 'wecom'
|
||||
? {
|
||||
...common,
|
||||
@@ -693,6 +760,12 @@ export class ChannelSettingsStore {
|
||||
if (!stored.enabled) {
|
||||
return
|
||||
}
|
||||
if (channel === 'telegram') {
|
||||
if (this.decryptCredential(channel, stored) === undefined) {
|
||||
throw new Error('启用 Telegram 前需要配置 Bot Token')
|
||||
}
|
||||
return
|
||||
}
|
||||
const identifier =
|
||||
channel === 'wecom'
|
||||
? (stored as StoredSettings['wecom']).botId
|
||||
@@ -732,6 +805,7 @@ export class ChannelSettingsStore {
|
||||
return undefined
|
||||
}
|
||||
const warn = (): undefined => {
|
||||
if (channel === 'telegram') return undefined
|
||||
this.addWarning({
|
||||
code:
|
||||
channel === 'wecom'
|
||||
@@ -750,7 +824,7 @@ export class ChannelSettingsStore {
|
||||
if (payload.channel !== channel) {
|
||||
return warn()
|
||||
}
|
||||
this.removeWarnings([
|
||||
if (channel !== 'telegram') this.removeWarnings([
|
||||
channel === 'wecom'
|
||||
? 'channel-wecom-credential-unreadable'
|
||||
: 'channel-dingtalk-credential-unreadable'
|
||||
@@ -825,7 +899,8 @@ export class ChannelSettingsStore {
|
||||
enabled: false
|
||||
},
|
||||
wecom: legacy.wecom,
|
||||
dingtalk: legacy.dingtalk
|
||||
dingtalk: legacy.dingtalk,
|
||||
telegram: structuredClone(defaultStoredSettings.telegram)
|
||||
}
|
||||
}
|
||||
await this.persist(this.settings)
|
||||
@@ -924,7 +999,8 @@ export class ChannelSettingsStore {
|
||||
: {})
|
||||
},
|
||||
wecom: settings.wecom,
|
||||
dingtalk: settings.dingtalk
|
||||
dingtalk: settings.dingtalk,
|
||||
telegram: structuredClone(defaultStoredSettings.telegram)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,6 +1015,30 @@ export class ChannelSettingsStore {
|
||||
private readEnvironmentChannel(
|
||||
channel: CredentialChannel
|
||||
): EnvironmentChannel {
|
||||
if (channel === 'telegram') {
|
||||
const secret = boundedEnvironmentValue(
|
||||
this.environment, 'GOODBUDDY_TELEGRAM_BOT_TOKEN',
|
||||
CHANNEL_SETTINGS_LIMITS.maximumSecretLength
|
||||
)
|
||||
const owned = secret.value !== undefined || secret.invalid
|
||||
const enabled = environmentBoolean(this.environment, 'GOODBUDDY_TELEGRAM_ENABLED', true)
|
||||
const senders = telegramAllowedSenderIdsSchema.safeParse(
|
||||
this.environment.GOODBUDDY_TELEGRAM_ALLOWED_SENDERS?.trim()
|
||||
? this.environment.GOODBUDDY_TELEGRAM_ALLOWED_SENDERS.split(',')
|
||||
: []
|
||||
)
|
||||
const groups = environmentBoolean(this.environment, 'GOODBUDDY_TELEGRAM_ALLOW_GROUPS', false)
|
||||
const invalid = secret.invalid || enabled.invalid || !senders.success || groups.invalid || groups.value
|
||||
return {
|
||||
owned,
|
||||
enabled: owned && !invalid && enabled.value,
|
||||
id: '',
|
||||
secret: secret.value,
|
||||
allowedSenderIds: senders.success ? senders.data : [],
|
||||
allowGroupMessages: false,
|
||||
...(owned && invalid ? { configurationError: 'Telegram 环境变量配置无效,请检查 Token、数字用户 ID 和私聊设置' } : {})
|
||||
}
|
||||
}
|
||||
const prefix =
|
||||
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
|
||||
const idName =
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createServer } from 'node:http'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ChannelService } from './channel-service'
|
||||
import { SqliteChannelOutbox } from './sqlite-channel-state'
|
||||
import {
|
||||
DingTalkChannelDriver,
|
||||
createOfficialDingTalkTransportFactory
|
||||
@@ -46,6 +50,65 @@ function envelope(
|
||||
}
|
||||
|
||||
describe('DingTalkChannelDriver', () => {
|
||||
it('retries an HTTP 200 business failure through the production transport and SQLite outbox', async () => {
|
||||
const requests: string[] = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += String(chunk)
|
||||
requests.push(body)
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify(requests.length === 1
|
||||
? { errcode: 130101, errmsg: 'synthetic rate limit' }
|
||||
: { errcode: 0, errmsg: 'ok' }))
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as { port: number }
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize('C:\\Workspace')
|
||||
const outbox = new SqliteChannelOutbox(database)
|
||||
let receive!: (message: { headers: { messageId: string }; data: string }) => void
|
||||
const driver = new DingTalkChannelDriver({
|
||||
clientId: 'client-id', clientSecret: 'synthetic-secret', allowedSenderIds: ['user-1'],
|
||||
transportFactory: createOfficialDingTalkTransportFactory({
|
||||
clientFactory: async () => ({
|
||||
registerCallbackListener: (_topic, listener) => { receive = listener },
|
||||
socketCallBackResponse: () => undefined,
|
||||
connect: async () => undefined, disconnect: () => undefined
|
||||
}),
|
||||
fetchImpl: (_input, init) => fetch(`http://127.0.0.1:${address.port}/reply`, init)
|
||||
})
|
||||
})
|
||||
const executor = vi.fn(async () => ({ status: 'completed', output: 'synthetic reply' }))
|
||||
const failure = vi.fn()
|
||||
const service = new ChannelService(driver, executor, {
|
||||
allowedSenderIds: ['user-1'], allowGroupMessages: true, outbox, onDeliveryFailure: failure
|
||||
})
|
||||
try {
|
||||
await service.start()
|
||||
receive(envelope())
|
||||
await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce())
|
||||
expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' })
|
||||
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'failed', attempts: 1 })
|
||||
await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 })
|
||||
await vi.waitFor(() => expect(outbox.listUndelivered()).toEqual([]))
|
||||
expect(requests[1]).toBe(requests[0])
|
||||
expect(executor).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await service.stop()
|
||||
database.close()
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['', '{}', 'null', 'not JSON', JSON.stringify({ errcode: 0, padding: 'x'.repeat(65_536) })])(
|
||||
'rejects invalid or oversized successful HTTP responses (%#)', async (body) => {
|
||||
const transport = await createOfficialDingTalkTransportFactory({
|
||||
fetchImpl: async () => new Response(body)
|
||||
}).create({ clientId: 'synthetic', clientSecret: 'synthetic' })
|
||||
await expect(transport.replyText(SESSION_WEBHOOK, 'reply')).rejects.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it('adapts group text and consumes only the issued reply context', async () => {
|
||||
const transport = new FakeTransport()
|
||||
const factory: DingTalkTransportFactory = {
|
||||
@@ -133,7 +196,7 @@ describe('DingTalkChannelDriver', () => {
|
||||
connect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 200 }))
|
||||
const fetchImpl = vi.fn(async () => Response.json({ errcode: 0, errmsg: 'ok' }))
|
||||
const factory = createOfficialDingTalkTransportFactory({
|
||||
clientFactory: async (credentials) => {
|
||||
expect(credentials).toEqual({
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ChannelResultMessage
|
||||
} from '../../shared/channel-contracts'
|
||||
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
|
||||
import { readBoundedResponseText } from '../../shared/node/bounded-response'
|
||||
import {
|
||||
DingTalkDriver,
|
||||
type DingTalkStreamEnvelope,
|
||||
@@ -133,6 +134,7 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport {
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort(new Error('钉钉回复超时'))
|
||||
}, REPLY_TIMEOUT_MS)
|
||||
let result: unknown
|
||||
try {
|
||||
const response = await this.fetchImpl(sessionWebhook, {
|
||||
method: 'POST',
|
||||
@@ -144,22 +146,26 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport {
|
||||
redirect: 'error',
|
||||
signal: controller.signal
|
||||
})
|
||||
const responseLength = Number(
|
||||
response.headers.get('content-length') ?? '0'
|
||||
)
|
||||
if (
|
||||
!response.ok ||
|
||||
!Number.isFinite(responseLength) ||
|
||||
responseLength > MAXIMUM_RESPONSE_BYTES
|
||||
) {
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel()
|
||||
throw new Error('钉钉回复请求失败')
|
||||
}
|
||||
await response.body?.cancel()
|
||||
result = JSON.parse(await readBoundedResponseText(response, {
|
||||
maxBytes: MAXIMUM_RESPONSE_BYTES,
|
||||
tooLargeMessage: '钉钉回复响应过大',
|
||||
truncatedMessage: '钉钉回复响应不完整'
|
||||
}))
|
||||
} catch {
|
||||
throw new Error('钉钉回复请求失败')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
if (typeof result !== 'object' || result === null || !('errcode' in result) || result.errcode !== 0) {
|
||||
const code = typeof result === 'object' && result !== null && 'errcode' in result &&
|
||||
typeof result.errcode === 'number' && Number.isSafeInteger(result.errcode)
|
||||
? ` (${result.errcode})` : ''
|
||||
throw new Error(`钉钉回复请求失败${code}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,12 +251,8 @@ export class DingTalkChannelDriver implements ChannelDriver {
|
||||
throw new Error('钉钉回复上下文无效或已过期')
|
||||
}
|
||||
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
await this.driver.reply(record.context, resultText(message))
|
||||
} catch {
|
||||
throw new Error('钉钉消息回复失败')
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
await this.driver.reply(record.context, resultText(message))
|
||||
if (!message.attachments?.length) {
|
||||
this.replyContexts.delete(message.eventId)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ describe('parseRemoteChannelPrompt', () => {
|
||||
expect(projectChannelLabels).toEqual({
|
||||
weixin: '微信 ClawBot',
|
||||
wecom: '企业微信',
|
||||
dingtalk: '钉钉'
|
||||
dingtalk: '钉钉',
|
||||
telegram: 'Telegram'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { SqliteChannelOutbox } from './sqlite-channel-state'
|
||||
|
||||
const databases: AssistantDatabase[] = []
|
||||
afterEach(() => {
|
||||
for (const database of databases.splice(0)) database.close()
|
||||
})
|
||||
|
||||
function createOutbox(): SqliteChannelOutbox {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
databases.push(database)
|
||||
database.initialize('C:\\Workspace')
|
||||
return new SqliteChannelOutbox(database)
|
||||
}
|
||||
|
||||
const message = {
|
||||
channel: 'telegram' as const,
|
||||
eventId: 'event-1',
|
||||
conversationId: 'chat-1',
|
||||
recipientId: 'user-1',
|
||||
status: 'completed' as const,
|
||||
output: 'Result',
|
||||
attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 1, kind: 'file' as const, dataBase64: 'eA==' }]
|
||||
}
|
||||
|
||||
describe('SqliteChannelOutbox', () => {
|
||||
it('marks a permanent failure terminal, releases media and counts the attempt once', () => {
|
||||
const outbox = createOutbox()
|
||||
const entry = outbox.enqueue(message)
|
||||
outbox.markTerminal(entry.id)
|
||||
outbox.markTerminal(entry.id)
|
||||
outbox.markFailed(entry.id)
|
||||
outbox.markDelivered(entry.id)
|
||||
expect(outbox.listUndelivered()).toEqual([{
|
||||
...entry, state: 'terminal', attempts: 1,
|
||||
message: { ...message, attachments: undefined }
|
||||
}])
|
||||
expect(outbox.listUndelivered()[0]!.message).not.toHaveProperty('attachments')
|
||||
})
|
||||
|
||||
it('keeps retryable entries ahead of terminal entries under channel and global limits', () => {
|
||||
const outbox = createOutbox()
|
||||
const terminal = outbox.enqueue(message)
|
||||
outbox.markTerminal(terminal.id)
|
||||
const retry = outbox.enqueue({ ...message, eventId: 'retry' })
|
||||
outbox.markFailed(retry.id)
|
||||
outbox.markFailed(retry.id)
|
||||
for (const channel of [undefined, 'telegram']) {
|
||||
expect(outbox.listUndelivered(channel, 1).map(entry => entry.id)).toEqual([retry.id])
|
||||
expect(outbox.listUndelivered(channel).map(entry => entry.id)).toEqual([retry.id, terminal.id])
|
||||
}
|
||||
expect(outbox.listUndelivered('weixin')).toEqual([])
|
||||
})
|
||||
|
||||
it('budgets retry bytes after putting terminal results last in both windows', () => {
|
||||
const outbox = createOutbox()
|
||||
// Retained terminal text alone exceeds the 20 MiB retry window.
|
||||
for (let index = 0; index < 22; index++) {
|
||||
const entry = outbox.enqueue({ ...message, eventId: `terminal-${index}`, output: 'x'.repeat(1024 * 1024) })
|
||||
outbox.markTerminal(entry.id)
|
||||
}
|
||||
const retry = outbox.enqueue({ ...message, eventId: 'retry' })
|
||||
outbox.markFailed(retry.id)
|
||||
outbox.markFailed(retry.id)
|
||||
for (const channel of [undefined, 'telegram']) {
|
||||
const entries = outbox.listUndelivered(channel)
|
||||
expect(entries[0]!.id).toBe(retry.id)
|
||||
expect(entries.length).toBeLessThan(23)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,10 @@ export class SqliteChannelOutbox implements Outbox {
|
||||
this.database.markChannelResult(id, 'failed')
|
||||
}
|
||||
|
||||
markTerminal(id: string): void {
|
||||
this.database.markChannelResult(id, 'terminal')
|
||||
}
|
||||
|
||||
listUndelivered(
|
||||
channel?: string,
|
||||
limit?: number
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { channelInboundTextSchema, type ChannelResultMessage } from '../../shared/channel-contracts'
|
||||
import { TelegramChannelDriver } from './telegram-channel-driver'
|
||||
import { ChannelService } from './channel-service'
|
||||
import { MemoryOutbox } from './channel-driver'
|
||||
|
||||
const netFetch = vi.hoisted(() => vi.fn())
|
||||
vi.mock('electron', () => ({ net: { fetch: netFetch } }))
|
||||
|
||||
const secret = 'test-only-placeholder'
|
||||
const drivers: TelegramChannelDriver[] = []
|
||||
const ok = (result: unknown) => Response.json({ ok: true, result })
|
||||
const error = (code: number, retryAfter = 2) => Response.json({ ok: false, error_code: code, parameters: { retry_after: retryAfter }, description: `https://api.telegram.org/bot${secret}/getUpdates` }, { status: code })
|
||||
const pending = (signal: AbortSignal) => new Promise<Response>((_resolve, reject) => {
|
||||
if (signal.aborted) reject(new Error(secret))
|
||||
else signal.addEventListener('abort', () => reject(new Error(secret)), { once: true })
|
||||
})
|
||||
const update = (id: number, text = 'hello', extra: Record<string, unknown> = {}) => ({
|
||||
update_id: id,
|
||||
message: { message_id: id + 100, date: 1234, from: { id: 7, is_bot: false }, chat: { id: 7, type: 'private' }, text, ...extra }
|
||||
})
|
||||
function setup(allowedSenderIds: string[] = ['7']) {
|
||||
const calls: { method: string; body: Record<string, unknown>; signal: AbortSignal }[] = []
|
||||
const polls: ((signal: AbortSignal) => Response | Promise<Response>)[] = []
|
||||
const sends: (() => Response | Promise<Response>)[] = []
|
||||
const onStatus = vi.fn()
|
||||
const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
|
||||
const method = String(input).split('/').at(-1)!
|
||||
const signal = init!.signal!
|
||||
calls.push({ method, body: JSON.parse(String(init!.body)), signal })
|
||||
if (method === 'getMe') return ok({ id: 42, is_bot: true, username: 'example_bot' })
|
||||
if (method === 'getWebhookInfo') return ok({ url: '' })
|
||||
if (method === 'getUpdates') return polls.shift()?.(signal) ?? pending(signal)
|
||||
if (method === 'sendMessage') return sends.shift()?.() ?? ok({ message_id: 99 })
|
||||
return ok(true)
|
||||
})
|
||||
const driver = new TelegramChannelDriver({ secret, allowedSenderIds, fetch, onStatus })
|
||||
drivers.push(driver)
|
||||
return { driver, calls, polls, sends, fetch, onStatus }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(drivers.splice(0).map((driver) => driver.stop()))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const result = (overrides: Partial<ChannelResultMessage> = {}): ChannelResultMessage => ({
|
||||
channel: 'telegram', eventId: '1', conversationId: '42:7', recipientId: '7', status: 'completed', output: 'answer', ...overrides
|
||||
})
|
||||
|
||||
describe('TelegramChannelDriver', () => {
|
||||
it('is lazy, validates with getMe/getWebhookInfo only, and never polls during repeated tests', async () => {
|
||||
const { driver, calls } = setup()
|
||||
expect(calls).toEqual([])
|
||||
expect(await driver.testConnection()).toEqual({ botId: '42', botUsername: 'example_bot' })
|
||||
await driver.testConnection()
|
||||
expect(calls.map((call) => call.method)).toEqual(['getMe', 'getWebhookInfo', 'getMe', 'getWebhookInfo'])
|
||||
})
|
||||
|
||||
it('refuses a webhook without deleting it or polling', async () => {
|
||||
const { driver, fetch, calls } = setup()
|
||||
fetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true })).mockResolvedValueOnce(ok({ url: 'https://example.invalid/hook' }))
|
||||
await expect(driver.testConnection()).rejects.toThrow('webhook')
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps test requests separate from the running poller', async () => {
|
||||
const { driver, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
await driver.testConnection()
|
||||
expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('allows bounded private help with an empty whitelist but no ordinary requests', async () => {
|
||||
const { driver, polls, calls } = setup([])
|
||||
polls.push(() => ok([update(1, '/whoami'), update(2, '/whoami'), update(3, 'ordinary'), update(4, '/start')]))
|
||||
const handler = vi.fn()
|
||||
await driver.start(handler)
|
||||
await vi.waitFor(() => expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(2))
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
expect(calls.find((call) => call.method === 'sendMessage')?.body.text).toBe('7')
|
||||
expect(calls.filter((call) => call.method === 'sendMessage')[1]?.body.text).toContain('Offline messages')
|
||||
})
|
||||
|
||||
it('requires numeric whitelist IDs', () => {
|
||||
expect(() => setup(['@somebody'])).toThrow('numeric')
|
||||
})
|
||||
|
||||
it('preserves all unknown slash text, Bot scope, event ID and millisecond date', async () => {
|
||||
const { driver, polls } = setup()
|
||||
polls.push(() => ok(['/ask', '/execute something', '/exec', '/unknown'].map((text, i) => update(i + 1, text))))
|
||||
const handler = vi.fn(async (message, ack) => { channelInboundTextSchema.parse(message); ack() })
|
||||
await driver.start(handler)
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(4))
|
||||
expect(handler.mock.calls.map(([message]) => message.text)).toEqual(['/ask', '/execute something', '/exec', '/unknown'])
|
||||
expect(handler.mock.calls[0]?.[0]).toEqual({ channel: 'telegram', accountId: '42', eventId: '1', senderId: '7', conversationId: '42:7', conversationType: 'direct', text: '/ask', mentioned: false, receivedAt: 1234000 })
|
||||
})
|
||||
|
||||
it('ignores groups, topics, bots, edits, unauthorized users and media captions', async () => {
|
||||
const { driver, polls, calls } = setup()
|
||||
polls.push(() => ok([
|
||||
update(1, '/whoami', { chat: { id: -7, type: 'group' } }),
|
||||
update(2, 'topic', { message_thread_id: 1 }),
|
||||
update(3, 'bot', { from: { id: 7, is_bot: true } }),
|
||||
{ update_id: 4, edited_message: update(4).message },
|
||||
update(5, 'unauthorized', { from: { id: 9, is_bot: false } }),
|
||||
update(6, '/start', { photo: [], caption: 'execute caption' }),
|
||||
update(7, '', { photo: [], from: { id: 9, is_bot: false } }),
|
||||
update(8, ' '), update(9, 'valid')
|
||||
]))
|
||||
const handler = vi.fn(async (_message, ack) => ack())
|
||||
await driver.start(handler)
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1))
|
||||
expect(handler.mock.calls[0]?.[0].text).toBe('valid')
|
||||
expect(calls.filter((call) => call.method === 'sendMessage').map((call) => call.body.text)).toEqual(['Only text messages are supported. Please describe your request in text.'])
|
||||
})
|
||||
|
||||
it('does not advance past unacknowledged input and skips duplicates after acknowledgment', async () => {
|
||||
const { driver, polls, calls } = setup()
|
||||
polls.push(() => ok([update(10)]), () => ok([update(10), update(11)]), () => ok([update(11)]))
|
||||
let attempts = 0
|
||||
const handler = vi.fn(async (_message, ack) => { if (++attempts > 1) ack() })
|
||||
await driver.start(handler)
|
||||
await vi.waitFor(() => expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(4), { timeout: 3000 })
|
||||
expect(calls.filter((call) => call.method === 'getUpdates').map((call) => call.body.offset)).toEqual([0, 0, 12, 12])
|
||||
expect(handler).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stop aborts long polling and waits for fetch cleanup', async () => {
|
||||
const { driver, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(calls.some((call) => call.method === 'getUpdates')).toBe(true))
|
||||
await driver.stop()
|
||||
expect(calls.find((call) => call.method === 'getUpdates')?.signal.aborted).toBe(true)
|
||||
await expect(driver.send(result(), new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('cancels validation without starting a poller', async () => {
|
||||
const { driver, fetch } = setup()
|
||||
fetch.mockImplementationOnce((_input, init) => pending(init!.signal!))
|
||||
await driver.start(vi.fn())
|
||||
const sending = driver.send(result(), new AbortController().signal)
|
||||
const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await driver.stop()
|
||||
await rejected
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits on 429 and cancellation interrupts retry_after', async () => {
|
||||
const { driver, polls, calls, onStatus } = setup()
|
||||
polls.push(() => error(429, 60))
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('rate limit') })))
|
||||
await driver.stop()
|
||||
expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('backs off transient failures and recovers status', async () => {
|
||||
const { driver, polls, onStatus, calls } = setup()
|
||||
polls.push(() => error(503), () => ok([]))
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(calls.filter((call) => call.method === 'getUpdates').length).toBeGreaterThanOrEqual(2), { timeout: 2500 })
|
||||
expect(onStatus.mock.calls.some(([status]) => status.lastError?.includes('503'))).toBe(true)
|
||||
expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'running' })
|
||||
})
|
||||
|
||||
it.each([401, 403, 409])('stops polling with a clear redacted %i error', async (code) => {
|
||||
const { driver, polls, calls, onStatus } = setup()
|
||||
polls.push(() => error(code))
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'error' })))
|
||||
const status = onStatus.mock.calls.at(-1)![0]
|
||||
expect(status.lastError).not.toContain(secret)
|
||||
expect(status.lastError).not.toContain('https://')
|
||||
expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('delivers accepted work after a polling conflict without terminating its outbox entry', async () => {
|
||||
const { driver, polls, calls, onStatus } = setup()
|
||||
const outbox = new MemoryOutbox()
|
||||
let finish!: (value: { status: string; output: string }) => void
|
||||
const executor = vi.fn(() => new Promise<{ status: string; output: string }>((resolve) => { finish = resolve }))
|
||||
const service = new ChannelService(driver, executor, { allowedSenderIds: ['7'], outbox })
|
||||
polls.push(() => ok([update(1)]), () => error(409))
|
||||
try {
|
||||
await service.start()
|
||||
await vi.waitFor(() => expect(executor).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(onStatus.mock.calls.at(-1)?.[0].state).toBe('error'))
|
||||
finish({ status: 'completed', output: 'accepted work result' })
|
||||
await vi.waitFor(() => expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1))
|
||||
expect(calls.find((call) => call.method === 'sendMessage')?.body.text).toBe('accepted work result')
|
||||
expect(outbox.listUndelivered()).toEqual([])
|
||||
expect(onStatus.mock.calls.at(-1)?.[0].state).toBe('error')
|
||||
} finally {
|
||||
await service.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('splits pure text at 4096 UTF16 units without splitting emoji and includes error and attachment notice', async () => {
|
||||
const { driver, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
const output = `${'a'.repeat(4095)}\uD83D\uDE00${'b'.repeat(4100)}`
|
||||
await driver.send(result({ output, error: 'task failed', attachments: [{ name: 'file.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'YQ==' }] }), new AbortController().signal)
|
||||
const parts = calls.filter((call) => call.method === 'sendMessage').map((call) => call.body.text as string)
|
||||
expect(parts).toHaveLength(3)
|
||||
expect(parts.every((part) => part.length <= 4096 && !/[\uD800-\uDBFF]$/u.test(part) && !/^[\uDC00-\uDFFF]/u.test(part))).toBe(true)
|
||||
expect(parts.join('')).toBe(`${output}\n\ntask failed\n\nAttachments are available in GoodBuddy on the desktop; no attachments were sent to Telegram.`)
|
||||
expect(calls.filter((call) => call.method === 'sendMessage').every((call) => call.body.parse_mode === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('resumes unsent chunks within the bounded in-memory progress cache', async () => {
|
||||
const { driver, sends, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
sends.push(() => ok(true), () => error(503))
|
||||
const message = result({ output: 'a'.repeat(4096) + 'tail' })
|
||||
await expect(driver.send(message, new AbortController().signal)).rejects.toThrow('503')
|
||||
await driver.send(message, new AbortController().signal)
|
||||
expect(calls.filter((call) => call.method === 'sendMessage').map((call) => call.body.text)).toEqual(['a'.repeat(4096), 'tail', 'tail'])
|
||||
})
|
||||
|
||||
it('refuses persisted outbox replies from another Bot and unscoped IDs', async () => {
|
||||
const { driver, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
for (const conversationId of ['41:7', '7', '42:-7', '42:8']) {
|
||||
await expect(driver.send(result({ conversationId }), new AbortController().signal)).rejects.toMatchObject({ message: expect.stringContaining('another Bot'), permanent: true, terminal: true })
|
||||
}
|
||||
expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('redacts fetch errors, raw Telegram URLs, Token and runtime errors', async () => {
|
||||
const { driver, fetch, calls } = setup()
|
||||
fetch.mockRejectedValueOnce(new Error(`https://api.telegram.org/bot${secret}/getMe`))
|
||||
await expect(driver.testConnection()).rejects.toThrow('network')
|
||||
await driver.start(vi.fn())
|
||||
await driver.send(result({ output: secret, error: `https://api.telegram.org/bot${secret}/getUpdates token=private` }), new AbortController().signal)
|
||||
const text = String(calls.find((call) => call.method === 'sendMessage')?.body.text)
|
||||
expect(text).not.toContain(secret)
|
||||
expect(text).not.toContain('api.telegram.org')
|
||||
expect(text).not.toContain('private')
|
||||
})
|
||||
|
||||
it('cancels outbound rate-limit waits and avoids repeating permanent send failures', async () => {
|
||||
const { driver, sends, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
sends.push(() => error(429, 60))
|
||||
const controller = new AbortController()
|
||||
const sending = driver.send(result(), controller.signal)
|
||||
const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1))
|
||||
controller.abort(new Error(secret))
|
||||
await rejected
|
||||
sends.push(() => error(403))
|
||||
await expect(driver.send(result(), new AbortController().signal)).rejects.toMatchObject({ permanent: true, terminal: true })
|
||||
await expect(driver.send(result(), new AbortController().signal)).rejects.toThrow('forbidden')
|
||||
expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses Electron net.fetch for the production network path', async () => {
|
||||
netFetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true })).mockResolvedValueOnce(ok({ url: '' }))
|
||||
const driver = new TelegramChannelDriver({ secret, allowedSenderIds: [] })
|
||||
drivers.push(driver)
|
||||
expect(netFetch).not.toHaveBeenCalled()
|
||||
await expect(driver.testConnection()).resolves.toEqual({ botId: '42' })
|
||||
expect(netFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('honors retry_after before retrying an outbound chunk', async () => {
|
||||
const { driver, sends, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
sends.push(() => error(429, 1), () => ok(true))
|
||||
const started = Date.now()
|
||||
await driver.send(result(), new AbortController().signal)
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(990)
|
||||
expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('stop cancels and joins active outbound requests', async () => {
|
||||
const { driver, fetch, calls } = setup()
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(calls.some((call) => call.method === 'getUpdates')).toBe(true))
|
||||
fetch.mockImplementationOnce((_input, init) => pending(init!.signal!))
|
||||
const sending = driver.send(result(), new AbortController().signal)
|
||||
const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4))
|
||||
await driver.stop()
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('retains HTTP authentication errors even for non-JSON responses', async () => {
|
||||
const { driver, fetch } = setup()
|
||||
fetch.mockResolvedValueOnce(new Response('not JSON', { status: 401 }))
|
||||
await expect(driver.testConnection()).rejects.toThrow('Token is invalid')
|
||||
})
|
||||
|
||||
it('returns from start while offline, reconnects and releases queued sends only after validation', async () => {
|
||||
const { driver, fetch, calls, onStatus } = setup()
|
||||
fetch.mockRejectedValueOnce(new Error(secret))
|
||||
await driver.start(vi.fn())
|
||||
const sending = driver.send(result(), new AbortController().signal)
|
||||
await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('Reconnecting') })))
|
||||
expect(calls).toHaveLength(0)
|
||||
await sending
|
||||
expect(calls.map((call) => call.method)).toEqual(['getMe', 'getWebhookInfo', 'getUpdates', 'sendMessage'])
|
||||
expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'running' })
|
||||
})
|
||||
|
||||
it('stop cancels startup backoff and all identity waiters', async () => {
|
||||
const { driver, fetch, onStatus } = setup()
|
||||
fetch.mockRejectedValueOnce(new Error(secret))
|
||||
await driver.start(vi.fn())
|
||||
const sending = driver.send(result(), new AbortController().signal)
|
||||
const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ lastError: expect.stringContaining('Reconnecting') })))
|
||||
await driver.stop()
|
||||
await rejected
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'stopped' })
|
||||
})
|
||||
|
||||
it('rechecks a transient webhook failure before polling after identifying the Bot', async () => {
|
||||
const { driver, fetch, calls, onStatus } = setup()
|
||||
fetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true }))
|
||||
.mockResolvedValueOnce(error(503))
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('503') })))
|
||||
expect(calls.some(call => call.method === 'getUpdates')).toBe(false)
|
||||
await vi.waitFor(() => expect(calls.some(call => call.method === 'getUpdates')).toBe(true), { timeout: 2500 })
|
||||
expect(fetch.mock.calls.map(([input]) => String(input).split('/').at(-1)))
|
||||
.toEqual(['getMe', 'getWebhookInfo', 'getWebhookInfo', 'getUpdates'])
|
||||
})
|
||||
|
||||
it('caller cancellation releases an identity waiter without cancelling startup', async () => {
|
||||
const { driver, fetch, onStatus } = setup()
|
||||
let validationSignal: AbortSignal | undefined
|
||||
fetch.mockImplementationOnce((_input, init) => {
|
||||
validationSignal = init!.signal!
|
||||
return pending(validationSignal)
|
||||
})
|
||||
await driver.start(vi.fn())
|
||||
const controller = new AbortController()
|
||||
const sending = driver.send(result(), controller.signal)
|
||||
const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
controller.abort()
|
||||
await rejected
|
||||
expect(validationSignal?.aborted).toBe(false)
|
||||
expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'starting' })
|
||||
})
|
||||
|
||||
it.each([401, 409])('reports terminal startup %i and rejects waiting sends without retrying', async (code) => {
|
||||
const { driver, fetch, onStatus } = setup()
|
||||
fetch.mockResolvedValueOnce(error(code))
|
||||
await driver.start(vi.fn())
|
||||
await expect(driver.send(result(), new AbortController().signal)).rejects.toMatchObject({ code, permanent: true, terminal: true })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(onStatus.mock.calls.at(-1)?.[0].state).toBe('error')
|
||||
})
|
||||
|
||||
it('updates the numeric whitelist atomically for subsequent input without restarting', async () => {
|
||||
const { driver, polls, calls } = setup()
|
||||
expect(() => driver.updateAllowedSenderIds(['invalid'])).toThrow('numeric')
|
||||
polls.push(() => ok([update(1), update(2), update(3, 'new user', { from: { id: 8, is_bot: false }, chat: { id: 8, type: 'private' } })]))
|
||||
const handler = vi.fn(async (_message, ack) => {
|
||||
ack()
|
||||
driver.updateAllowedSenderIds(['8'])
|
||||
})
|
||||
await driver.start(handler)
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2))
|
||||
expect(handler.mock.calls.map(([message]) => message.senderId)).toEqual(['7', '8'])
|
||||
expect(calls.filter((call) => call.method === 'getMe')).toHaveLength(1)
|
||||
expect(calls.every((call) => !call.signal.aborted)).toBe(true)
|
||||
// Previously authorized work can still deliver after its sender is removed.
|
||||
await driver.send(result(), new AbortController().signal)
|
||||
expect(calls.at(-1)?.method).toBe('sendMessage')
|
||||
})
|
||||
|
||||
it('help rate limits never hold up subsequent inbound requests', async () => {
|
||||
const { driver, polls, sends, calls } = setup()
|
||||
sends.push(() => error(429, 86400))
|
||||
polls.push(() => ok([update(1, '/start'), update(2, 'business request')]))
|
||||
const handler = vi.fn(async (_message, ack) => ack())
|
||||
await driver.start(handler)
|
||||
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1))
|
||||
expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('testConnection fails fast on rate limiting and cannot replace the running identity', async () => {
|
||||
const { driver, fetch, calls } = setup()
|
||||
fetch.mockResolvedValueOnce(error(429, 86400))
|
||||
await expect(driver.testConnection()).rejects.toMatchObject({ code: 429 })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
await driver.start(vi.fn())
|
||||
await vi.waitFor(() => expect(calls.some((call) => call.method === 'getUpdates')).toBe(true))
|
||||
fetch.mockResolvedValueOnce(ok({ id: 99, is_bot: true })).mockResolvedValueOnce(ok({ url: '' }))
|
||||
await expect(driver.testConnection()).resolves.toEqual({ botId: '99' })
|
||||
await driver.send(result(), new AbortController().signal)
|
||||
await expect(driver.send(result({ conversationId: '99:7' }), new AbortController().signal)).rejects.toMatchObject({ terminal: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import type { ChannelInboundText, ChannelResultMessage } from '../../shared/channel-contracts'
|
||||
import type { ChannelRuntimeStatus } from '../../shared/channel-settings-contracts'
|
||||
import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
|
||||
import { redactChannelError } from './channel-service'
|
||||
|
||||
export type TelegramChannelDriverOptions = {
|
||||
secret: string
|
||||
allowedSenderIds: readonly string[]
|
||||
fetch?: typeof globalThis.fetch
|
||||
onStatus?: (status: ChannelRuntimeStatus) => void
|
||||
}
|
||||
|
||||
type BotIdentity = { botId: string; botUsername?: string }
|
||||
type TelegramUpdate = {
|
||||
update_id: number
|
||||
message?: {
|
||||
message_id: number
|
||||
date: number
|
||||
from?: { id: number; is_bot?: boolean }
|
||||
chat: { id: number; type: string }
|
||||
text?: string
|
||||
message_thread_id?: number
|
||||
is_topic_message?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export class TelegramError extends Error {
|
||||
constructor(readonly code: number, readonly retryAfter = 1) {
|
||||
super(code === 401 ? 'Telegram Token is invalid. Replace the Bot Token.'
|
||||
: code === 409 ? 'Telegram polling conflict. Stop the other poller or remove the existing webhook.'
|
||||
: code === 403 ? 'Telegram access forbidden. The Bot may be blocked or disabled.'
|
||||
: code === 429 ? 'Telegram rate limit. Waiting before retrying.'
|
||||
: code === 0 ? 'Telegram network request failed. Check the network and OS proxy.'
|
||||
: `Telegram request failed (HTTP ${code}).`)
|
||||
}
|
||||
|
||||
get permanent(): boolean {
|
||||
return this.code >= 400 && this.code < 500 && this.code !== 429
|
||||
}
|
||||
|
||||
get terminal(): boolean {
|
||||
return this.permanent
|
||||
}
|
||||
}
|
||||
|
||||
function cancelled(): Error {
|
||||
const error = new Error('Telegram operation cancelled.')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function checkSignal(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw cancelled()
|
||||
}
|
||||
|
||||
async function wait(ms: number, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
await delay(ms, undefined, { signal })
|
||||
} catch {
|
||||
throw cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
function positiveId(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
||||
}
|
||||
|
||||
export class TelegramChannelDriver implements ChannelDriver {
|
||||
readonly channel = 'telegram'
|
||||
private allowed: ReadonlySet<string> = new Set()
|
||||
private readonly fetchImpl: typeof globalThis.fetch
|
||||
private lifecycle = new AbortController()
|
||||
private readonly operations = new Set<Promise<unknown>>()
|
||||
private readonly feedback = new Map<string, number>()
|
||||
private readonly progress = new Map<string, { offset: number; terminal?: TelegramError }>()
|
||||
private identity?: BotIdentity
|
||||
private terminalFailure?: TelegramError
|
||||
private readonly readinessWaiters = new Set<() => void>()
|
||||
private offset = 0
|
||||
private pollPromise?: Promise<void>
|
||||
private stopPromise?: Promise<void>
|
||||
private lastStatus?: string
|
||||
|
||||
constructor(private readonly options: TelegramChannelDriverOptions) {
|
||||
if (!options.secret.trim() || /[\s/?#]/u.test(options.secret)) {
|
||||
throw new Error('Telegram Bot Token is invalid.')
|
||||
}
|
||||
this.updateAllowedSenderIds(options.allowedSenderIds)
|
||||
this.fetchImpl = options.fetch ?? (async (input, init) => {
|
||||
const { net } = await import('electron')
|
||||
return net.fetch(input instanceof Request ? input.url : String(input), init)
|
||||
})
|
||||
}
|
||||
|
||||
testConnection(): Promise<BotIdentity> {
|
||||
return this.track((async () => {
|
||||
const identity = await this.identify(this.lifecycle.signal)
|
||||
await this.checkWebhook(this.lifecycle.signal)
|
||||
return identity
|
||||
})())
|
||||
}
|
||||
|
||||
updateAllowedSenderIds(ids: readonly string[]): void {
|
||||
if (ids.some((id) => !/^[1-9]\d*$/u.test(id))) {
|
||||
throw new Error('Telegram whitelist must contain numeric user IDs.')
|
||||
}
|
||||
this.allowed = new Set(ids)
|
||||
}
|
||||
|
||||
start(handler: ChannelInboundHandler): Promise<void> {
|
||||
if (this.stopPromise) return Promise.reject(new Error('Telegram driver has stopped. Create a new driver to restart.'))
|
||||
if (this.pollPromise) return Promise.resolve()
|
||||
this.status({ state: 'starting' })
|
||||
this.pollPromise = this.poll(handler, this.lifecycle.signal)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
if (this.stopPromise) return this.stopPromise
|
||||
this.lifecycle.abort()
|
||||
this.stopPromise = (async () => {
|
||||
await Promise.allSettled([...this.operations, this.pollPromise])
|
||||
this.feedback.clear()
|
||||
this.progress.clear()
|
||||
this.status({ state: 'stopped' })
|
||||
})()
|
||||
return this.stopPromise
|
||||
}
|
||||
|
||||
send(message: ChannelResultMessage, signal: AbortSignal): Promise<void> {
|
||||
return this.track(this.sendResult(message, AbortSignal.any([signal, this.lifecycle.signal])))
|
||||
}
|
||||
|
||||
private async sendResult(message: ChannelResultMessage, signal: AbortSignal): Promise<void> {
|
||||
checkSignal(signal)
|
||||
await this.waitForIdentity(signal)
|
||||
const match = /^([1-9]\d*):([1-9]\d*)$/u.exec(message.conversationId)
|
||||
if (!this.identity || message.channel !== this.channel || !match ||
|
||||
match[1] !== this.identity.botId || match[2] !== message.recipientId) {
|
||||
throw Object.assign(new Error('Telegram reply belongs to another Bot or has an invalid private conversation.'), { permanent: true, terminal: true })
|
||||
}
|
||||
const text = this.redact([
|
||||
message.output?.trim(), message.error?.trim(),
|
||||
message.attachments?.length ? 'Attachments are available in GoodBuddy on the desktop; no attachments were sent to Telegram.' : undefined
|
||||
].filter(Boolean).join('\n\n') || 'Request completed.')
|
||||
const key = `${message.conversationId}:${message.eventId}:${message.status}`
|
||||
let record = this.progress.get(key)
|
||||
if (!record) {
|
||||
record = { offset: 0 }
|
||||
this.progress.set(key, record)
|
||||
if (this.progress.size > 128) this.progress.delete(this.progress.keys().next().value!)
|
||||
}
|
||||
if (record.terminal) throw record.terminal
|
||||
while (record.offset < text.length) {
|
||||
let end = Math.min(record.offset + 4096, text.length)
|
||||
if (end < text.length && /[\uD800-\uDBFF]/u.test(text.charAt(end - 1)) && /[\uDC00-\uDFFF]/u.test(text.charAt(end))) end -= 1
|
||||
try {
|
||||
await this.request('sendMessage', { chat_id: match[2], text: text.slice(record.offset, end) }, signal, true)
|
||||
record.offset = end
|
||||
} catch (error) {
|
||||
if (error instanceof TelegramError && error.permanent) record.terminal = error
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.progress.delete(key)
|
||||
}
|
||||
|
||||
private async waitForIdentity(signal: AbortSignal): Promise<void> {
|
||||
checkSignal(signal)
|
||||
if (!this.identity && !this.terminalFailure) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = () => {
|
||||
this.readinessWaiters.delete(finish)
|
||||
signal.removeEventListener('abort', finish)
|
||||
if (signal.aborted) reject(cancelled())
|
||||
else resolve()
|
||||
}
|
||||
this.readinessWaiters.add(finish)
|
||||
signal.addEventListener('abort', finish, { once: true })
|
||||
})
|
||||
}
|
||||
checkSignal(signal)
|
||||
// A polling conflict does not prevent an identified Bot from sending replies.
|
||||
if (this.terminalFailure && !(this.identity && this.terminalFailure.code === 409)) {
|
||||
throw this.terminalFailure
|
||||
}
|
||||
}
|
||||
|
||||
private async identify(signal: AbortSignal): Promise<BotIdentity> {
|
||||
const me = await this.request<{ id: number; is_bot: boolean; username?: string }>('getMe', {}, signal)
|
||||
if (!positiveId(me?.id) || me.is_bot !== true) throw new Error('Telegram returned an invalid Bot identity.')
|
||||
return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) }
|
||||
}
|
||||
|
||||
private async checkWebhook(signal: AbortSignal): Promise<void> {
|
||||
const webhook = await this.request<{ url: string }>('getWebhookInfo', {}, signal)
|
||||
if (typeof webhook?.url !== 'string') throw new Error('Telegram returned invalid webhook information.')
|
||||
if (webhook.url) throw new TelegramError(409)
|
||||
}
|
||||
|
||||
private async poll(handler: ChannelInboundHandler, signal: AbortSignal): Promise<void> {
|
||||
let failures = 0
|
||||
let pollingReady = false
|
||||
while (!signal.aborted) {
|
||||
const started = Date.now()
|
||||
try {
|
||||
if (!this.identity) {
|
||||
// Outgoing replies need the Bot identity, not permission to long-poll.
|
||||
this.identity = await this.identify(signal)
|
||||
checkSignal(signal)
|
||||
}
|
||||
if (!pollingReady) {
|
||||
await this.checkWebhook(signal)
|
||||
pollingReady = true
|
||||
for (const notify of this.readinessWaiters) notify()
|
||||
failures = 0
|
||||
this.status({ state: 'running' })
|
||||
}
|
||||
const updates = await this.request<TelegramUpdate[]>('getUpdates', {
|
||||
offset: this.offset, timeout: 30, limit: 100, allowed_updates: ['message']
|
||||
}, signal)
|
||||
if (!Array.isArray(updates)) throw new TelegramError(0)
|
||||
for (const update of updates) {
|
||||
checkSignal(signal)
|
||||
if (!Number.isSafeInteger(update.update_id) || update.update_id < this.offset) continue
|
||||
let acknowledged = false
|
||||
await this.receive(update, handler, () => { acknowledged = true }, signal)
|
||||
checkSignal(signal)
|
||||
if (!acknowledged) throw new Error('Telegram inbound message was not acknowledged.')
|
||||
this.offset = update.update_id + 1
|
||||
}
|
||||
failures = 0
|
||||
this.status({ state: 'running' })
|
||||
// A server/proxy returning immediately must not turn long polling into a busy loop.
|
||||
await wait(Math.max(0, 250 - (Date.now() - started)), signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) break
|
||||
const safe = this.safeError(error)
|
||||
if (safe instanceof TelegramError && safe.permanent) {
|
||||
this.terminalFailure = safe
|
||||
for (const notify of this.readinessWaiters) notify()
|
||||
this.status({ state: 'error', lastError: safe.message })
|
||||
break
|
||||
}
|
||||
this.status({ state: 'starting', lastError: `${safe.message} Reconnecting.` })
|
||||
const pause = safe instanceof TelegramError && safe.code === 429
|
||||
? safe.retryAfter * 1000 : Math.min(30_000, 1000 * 2 ** Math.min(failures++, 5))
|
||||
try { await wait(pause, signal) } catch { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async receive(update: TelegramUpdate, handler: ChannelInboundHandler, acknowledge: () => void, signal: AbortSignal): Promise<void> {
|
||||
const message = update.message
|
||||
if (!message || message.chat?.type !== 'private' || !positiveId(message.chat.id) ||
|
||||
!positiveId(message.from?.id) || message.from.is_bot !== false ||
|
||||
message.message_thread_id !== undefined || message.is_topic_message) {
|
||||
acknowledge()
|
||||
return
|
||||
}
|
||||
const senderId = String(message.from.id)
|
||||
const media = ['caption', 'photo', 'video', 'animation', 'audio', 'voice', 'document', 'sticker', 'video_note', 'contact', 'location', 'venue', 'poll', 'dice', 'paid_media'].some((key) => message[key] !== undefined)
|
||||
const text = typeof message.text === 'string' && !media ? message.text.trim() : ''
|
||||
const command = /^\/(start|whoami)(?:@([^\s]+))?(?:\s|$)/u.exec(text)
|
||||
const help = command && (!command[2] || command[2].toLowerCase() === this.identity?.botUsername?.toLowerCase())
|
||||
if (help || (media && this.allowed.has(senderId))) {
|
||||
const now = Date.now()
|
||||
const feedbackKey = `${senderId}:${help ? command[1] : 'media'}`
|
||||
if (now - (this.feedback.get(feedbackKey) ?? -Infinity) >= 5000) {
|
||||
this.feedback.delete(feedbackKey)
|
||||
this.feedback.set(feedbackKey, now)
|
||||
if (this.feedback.size > 128) this.feedback.delete(this.feedback.keys().next().value!)
|
||||
const reply = help ? command[1] === 'whoami' ? senderId
|
||||
: 'GoodBuddy accepts private text requests from authorized users. Send /whoami and add your numeric ID to the desktop whitelist. Offline messages retained by Telegram may run when reconnected. After a crash, check desktop task records and results before resending; tasks do not automatically resume.'
|
||||
: 'Only text messages are supported. Please describe your request in text.'
|
||||
try {
|
||||
await this.request('sendMessage', { chat_id: String(message.chat.id), text: reply }, signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error
|
||||
this.status({ state: 'running', lastError: this.safeError(error).message })
|
||||
}
|
||||
}
|
||||
acknowledge()
|
||||
return
|
||||
}
|
||||
if (!text || !this.allowed.has(senderId) || !Number.isSafeInteger(message.date) || message.date < 0) {
|
||||
acknowledge()
|
||||
return
|
||||
}
|
||||
const inbound: ChannelInboundText = {
|
||||
channel: this.channel, accountId: this.identity!.botId,
|
||||
eventId: String(update.update_id), senderId,
|
||||
conversationId: `${this.identity!.botId}:${message.chat.id}`,
|
||||
conversationType: 'direct', text, mentioned: false, receivedAt: message.date * 1000
|
||||
}
|
||||
await handler(inbound, acknowledge)
|
||||
checkSignal(signal)
|
||||
// Help commands have separate cooldowns so /start does not suppress /whoami.
|
||||
const typingKey = `${senderId}:typing`
|
||||
if (Date.now() - (this.feedback.get(typingKey) ?? -Infinity) >= 5000) {
|
||||
this.feedback.delete(typingKey)
|
||||
this.feedback.set(typingKey, Date.now())
|
||||
if (this.feedback.size > 128) this.feedback.delete(this.feedback.keys().next().value!)
|
||||
try { await this.request('sendChatAction', { chat_id: String(message.chat.id), action: 'typing' }, signal) } catch { /* Feedback must not reject an already queued request. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T = unknown>(method: string, body: object, signal: AbortSignal, retryRateLimit = false): Promise<T> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
checkSignal(signal)
|
||||
const timeout = new AbortController()
|
||||
const timer = setTimeout(() => timeout.abort(), method === 'getUpdates' ? 40_000 : 15_000)
|
||||
let failure: TelegramError
|
||||
try {
|
||||
const response = await this.fetchImpl(`https://api.telegram.org/bot${this.options.secret}/${method}`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
||||
redirect: 'error', signal: AbortSignal.any([signal, timeout.signal])
|
||||
})
|
||||
const data = await response.json().catch(() => ({})) as { ok?: boolean; result?: T; error_code?: number; parameters?: { retry_after?: number } }
|
||||
checkSignal(signal)
|
||||
if (response.ok && data.ok === true) return data.result as T
|
||||
const code = response.ok ? (Number.isSafeInteger(data.error_code) ? data.error_code! : 0) : response.status
|
||||
const seconds = data.parameters?.retry_after
|
||||
failure = new TelegramError(code, typeof seconds === 'number' && Number.isFinite(seconds) ? Math.max(1, Math.min(seconds, 86400)) : 1)
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw cancelled()
|
||||
failure = error instanceof TelegramError ? error : new TelegramError(0)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (failure.code !== 429 || !retryRateLimit) throw failure
|
||||
// The outbox may retry as soon as this call rejects, including the final attempt.
|
||||
await wait(failure.retryAfter * 1000, signal)
|
||||
if (attempt >= 3) throw failure
|
||||
}
|
||||
}
|
||||
|
||||
private redact(value: string): string {
|
||||
return redactChannelError(value
|
||||
.replace(/https?:\/\/api\.telegram\.org\/[^\s"'<>]+/giu, '[Telegram URL hidden]')
|
||||
.split(this.options.secret).join('[Token hidden]'))
|
||||
}
|
||||
|
||||
private safeError(error: unknown): Error {
|
||||
return error instanceof TelegramError ? error : new Error('Telegram operation failed. Check the connection and inbound queue.')
|
||||
}
|
||||
|
||||
private status(status: ChannelRuntimeStatus): void {
|
||||
const key = JSON.stringify(status)
|
||||
if (key === this.lastStatus) return
|
||||
this.lastStatus = key
|
||||
this.options.onStatus?.(status)
|
||||
}
|
||||
|
||||
private track<T>(promise: Promise<T>): Promise<T> {
|
||||
this.operations.add(promise)
|
||||
void promise.finally(() => this.operations.delete(promise)).catch(() => undefined)
|
||||
return promise
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment node
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import { ChannelManager } from './channel-manager'
|
||||
import { ChannelSettingsStore } from './channel-settings-store'
|
||||
import { SqliteChannelOutbox } from './sqlite-channel-state'
|
||||
import { TelegramChannelDriver } from './telegram-channel-driver'
|
||||
import type { ChannelRuntimeStatusChange } from '../../shared/channel-settings-contracts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-delivery-'))
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(root)
|
||||
const outbox = new SqliteChannelOutbox(database)
|
||||
const settings = new ChannelSettingsStore(join(root, 'channels.json'), {
|
||||
isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString()
|
||||
}, {})
|
||||
const secret = 'synthetic-telegram-delivery-token'
|
||||
await settings.apply({ telegram: { enabled: true, secret: { action: 'replace', value: secret },
|
||||
allowedSenderIds: ['7'], allowGroupMessages: false } })
|
||||
const state = { webhook: '', pollingCode: 0, polls: 0,
|
||||
updates: [] as object[], sends: [] as { text: string; time: number }[],
|
||||
sendErrors: [] as { code: number; retryAfter?: number }[] }
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += String(chunk)
|
||||
const method = request.url!.slice(1)
|
||||
let result: unknown
|
||||
let failure: { code: number; retryAfter?: number } | undefined
|
||||
if (method === 'getMe') result = { id: 42, is_bot: true }
|
||||
else if (method === 'getWebhookInfo') result = { url: state.webhook }
|
||||
else if (method === 'getUpdates') {
|
||||
state.polls++
|
||||
if (state.pollingCode) failure = { code: state.pollingCode }
|
||||
else result = state.updates.splice(0)
|
||||
} else if (method === 'sendMessage') {
|
||||
state.sends.push({ text: JSON.parse(body).text, time: performance.now() })
|
||||
failure = state.sendErrors.shift()
|
||||
result = { message_id: state.sends.length }
|
||||
} else if (method === 'sendChatAction') result = true
|
||||
else throw new Error('Unexpected fixture method')
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.statusCode = failure?.code ?? 200
|
||||
response.end(JSON.stringify(failure
|
||||
? { ok: false, error_code: failure.code, parameters: { retry_after: failure.retryAfter },
|
||||
description: `token=${secret} https://api.telegram.org/bot${secret}/sendMessage` }
|
||||
: { ok: true, result }))
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address() as { port: number }
|
||||
const execute = vi.fn(async () => ({ status: 'completed', output: 'synthetic result' }))
|
||||
const changes: ChannelRuntimeStatusChange[] = []
|
||||
const manager = new ChannelManager(settings, execute, {
|
||||
outbox, onStatusChanged: change => changes.push(change),
|
||||
createDriver: (resolved, onStatus) => new TelegramChannelDriver({
|
||||
secret: resolved.channel === 'telegram' ? resolved.secret! : '',
|
||||
allowedSenderIds: resolved.allowedSenderIds, onStatus,
|
||||
fetch: (input, init) => fetch(`http://127.0.0.1:${address.port}/${String(input).split('/').at(-1)}`, init)
|
||||
})
|
||||
})
|
||||
return { state, outbox, manager, execute, changes, secret,
|
||||
enqueue: (eventId: string, bot = '42') => outbox.enqueue({ channel: 'telegram', eventId,
|
||||
conversationId: `${bot}:7`, recipientId: '7', status: 'completed', output: eventId }),
|
||||
inbound: (id: number) => state.updates.push({ update_id: id, message: { message_id: id,
|
||||
date: 1234, from: { id: 7, is_bot: false }, chat: { id: 7, type: 'private' }, text: 'request' } }),
|
||||
async close() {
|
||||
await manager.stopAll()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Telegram delivery through loopback HTTP and SQLite', () => {
|
||||
it('delivers identity-matched pending replies during a startup webhook conflict without sending another Bot results', async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
f.state.webhook = 'https://example.invalid/webhook'
|
||||
f.enqueue('same-bot')
|
||||
f.enqueue('other-bot', '41')
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toMatchObject({
|
||||
state: 'error', lastError: expect.stringContaining('polling conflict')
|
||||
}))
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered().every(entry => entry.state === 'terminal')).toBe(true))
|
||||
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
|
||||
expect(f.outbox.listUndelivered().map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
|
||||
expect(f.state.polls).toBe(0)
|
||||
f.state.webhook = ''
|
||||
await f.manager.reload('telegram')
|
||||
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status.state).toBe('running'))
|
||||
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
|
||||
expect(f.execute).not.toHaveBeenCalled()
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
it('surfaces sanitized sending failures without changing polling state or clearing them on healthy polls', async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
f.state.sendErrors.push({ code: 403 })
|
||||
f.inbound(1)
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
|
||||
await vi.waitFor(() => expect(f.state.polls).toBeGreaterThanOrEqual(2))
|
||||
expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running',
|
||||
lastError: expect.stringMatching(/sending failed.*forbidden/i) })
|
||||
expect(JSON.stringify(f.changes)).not.toContain(f.secret)
|
||||
expect(JSON.stringify(f.changes)).not.toContain('api.telegram.org')
|
||||
f.inbound(2)
|
||||
await vi.waitFor(() => expect(f.state.sends).toHaveLength(2))
|
||||
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running' }))
|
||||
expect(f.execute).toHaveBeenCalledTimes(2)
|
||||
} finally { await f.close() }
|
||||
})
|
||||
|
||||
it('honors the latest retry_after when an exhausted driver attempt passes to the outbox', async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 },
|
||||
{ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 3 })
|
||||
f.inbound(1)
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.state.sends).toHaveLength(5), { timeout: 10_000 })
|
||||
expect(f.state.sends[4]!.time - f.state.sends[3]!.time).toBeGreaterThanOrEqual(2995)
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
|
||||
expect(f.state.sends.every(send => send.text === 'synthetic result')).toBe(true)
|
||||
expect(f.execute).toHaveBeenCalledOnce()
|
||||
} finally { await f.close() }
|
||||
}, 15_000)
|
||||
|
||||
it('cancels the final rate-limit wait without consuming or duplicating the pending reply', async () => {
|
||||
const f = await fixture()
|
||||
try {
|
||||
f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 },
|
||||
{ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 86400 })
|
||||
f.enqueue('cancelled-wait')
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.state.sends).toHaveLength(4), { timeout: 5000 })
|
||||
await f.manager.stopAll()
|
||||
expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'pending', attempts: 0 })
|
||||
expect(f.state.sends).toHaveLength(4)
|
||||
await f.manager.initialize()
|
||||
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
|
||||
expect(f.state.sends).toHaveLength(5)
|
||||
expect(f.execute).not.toHaveBeenCalled()
|
||||
} finally { await f.close() }
|
||||
}, 10_000)
|
||||
})
|
||||
+235
-34
@@ -13,6 +13,7 @@ import {
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type {
|
||||
AssistantProject,
|
||||
@@ -35,6 +36,10 @@ import type {
|
||||
} from '../shared/ssh-host-contracts'
|
||||
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
import { ChannelSettingsStore } from './channels/channel-settings-store'
|
||||
import { ChannelService } from './channels/channel-service'
|
||||
import { SqliteChannelOutbox } from './channels/sqlite-channel-state'
|
||||
import type { ChannelDriver, ChannelInboundHandler } from './channels/channel-driver'
|
||||
import { SubagentService, createSubagentRuntime } from './assistant/subagent-service'
|
||||
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
|
||||
import { DocumentResultStorage } from './document-result-storage'
|
||||
@@ -350,6 +355,7 @@ const electronMocks = vi.hoisted(() => {
|
||||
return {
|
||||
handlers,
|
||||
invoke: vi.fn(),
|
||||
fetch: vi.fn<typeof fetch>(),
|
||||
handle: vi.fn((channel: string, handler: InvokeHandler) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
@@ -1391,6 +1397,7 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: electronMocks.fetch },
|
||||
contextBridge: { exposeInMainWorld: (name: string, value: unknown) => vi.stubGlobal(name, value) },
|
||||
ipcRenderer: { invoke: electronMocks.invoke },
|
||||
webUtils: {},
|
||||
@@ -5374,7 +5381,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
obsidianService?: ObsidianService,
|
||||
nativeClientCoordinator?: Parameters<typeof registerIpcHandlers>[44],
|
||||
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[40],
|
||||
applicationSettingsStore?: ApplicationSettingsStore
|
||||
applicationSettingsStore?: ApplicationSettingsStore,
|
||||
channelSettingsStore?: ChannelSettingsStore
|
||||
) {
|
||||
runtime.getStatus ??= vi.fn(async () => ({
|
||||
id: runtime.runtimeId ?? 'model', available: true,
|
||||
@@ -5625,6 +5633,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
args[44] = nativeClientCoordinator
|
||||
args[40] = nativeTerminalManager
|
||||
if (applicationSettingsStore) args[14] = applicationSettingsStore
|
||||
args[13] = channelSettingsStore
|
||||
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
|
||||
const dispose = registerIpcHandlers(...args)
|
||||
return {
|
||||
@@ -6448,6 +6457,50 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
} finally { await harness.dispose(); database.close() }
|
||||
})
|
||||
|
||||
it('routes Supervisor overview, graph and stories IPC through the real readonly worker without Main fallback', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'supervision-ipc-worker-'))
|
||||
const worker = join(directory, 'worker.cjs')
|
||||
execFileSync(process.execPath, ['-e', `require('esbuild').buildSync(${JSON.stringify({
|
||||
entryPoints: [join(process.cwd(), 'src/main/readonly-query-worker.ts')], outfile: worker,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent'
|
||||
})})`], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } })
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
const at = '2026-10-01T12:00:00.000Z'
|
||||
database.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: at, to: at } },
|
||||
evidence: [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: 'Source', content: 'Full body', occurredAt: at }],
|
||||
output: { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } })
|
||||
const overview = database.listSupervisionResults()
|
||||
const resultId = overview[0]!.id
|
||||
const sourceId = overview[0]!.sourceId!
|
||||
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
|
||||
false, undefined, undefined, undefined, undefined, database)
|
||||
try {
|
||||
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const requests = [[ipcChannels.supervisionOverview, {}], [ipcChannels.supervisionGraph, { resultId }],
|
||||
[ipcChannels.supervisionStories, { scope: { kind: 'global' } }]] as const
|
||||
for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('worker is not configured')
|
||||
database.enableReadonlyWorker(worker)
|
||||
const reads = [vi.spyOn(database, 'listSupervisionResults'), vi.spyOn(database, 'getSupervisionGraph'), vi.spyOn(database, 'supervisionStories')]
|
||||
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(event, {})).resolves.toEqual(overview)
|
||||
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(event, { resultId })).resolves.toMatchObject({
|
||||
sources: [{ id: sourceId, title: 'Source', occurred_at: at }] })
|
||||
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
|
||||
stories: [], experiences: [], unassigned: 0, canUndo: false })
|
||||
expect(electronMocks.handlers.get(ipcChannels.supervisionSource)!(event, { sourceId })).toMatchObject({ content: 'Full body' })
|
||||
await database.readonlyWorkerForTest!.terminateWorkerForTest()
|
||||
for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('backing off')
|
||||
for (const read of reads) expect(read).not.toHaveBeenCalled()
|
||||
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
|
||||
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
|
||||
stories: [], experiences: [], unassigned: 0, canUndo: false })
|
||||
} finally {
|
||||
await harness.dispose(); database.close()
|
||||
await rm(directory, { recursive: true, force: true, maxRetries: 10 })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('scopes supervision continuation context to source-linked graph evidence', async () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
@@ -6642,12 +6695,12 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await expect(activity(trustedEvent(harness.webContents), { configId: 'unrelated-plan' })).resolves.toEqual([])
|
||||
await expect(activity(trustedEvent(harness.webContents), { configId: '' })).rejects.toThrow()
|
||||
await expect(activity({ sender: {} }, {})).rejects.toThrow()
|
||||
expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).toEqual(overview)
|
||||
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).resolves.toEqual(overview)
|
||||
expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId)
|
||||
const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] }
|
||||
expect(graph.sources).toHaveLength(evidence.length)
|
||||
expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }])
|
||||
expect(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配')
|
||||
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).rejects.toThrow('不匹配')
|
||||
expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配')
|
||||
const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to))
|
||||
expect(legacy.tasks.some(task => task.id === oldTask.id)).toBe(false)
|
||||
@@ -6670,14 +6723,19 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
|
||||
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
|
||||
let first = true
|
||||
let providerFailures = ending === 'provider' ? 2 : 0
|
||||
const releaseConversation = vi.fn(async () => undefined)
|
||||
if (ending === 'cleanup') releaseConversation.mockRejectedValueOnce(new Error('Release failed'))
|
||||
const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) {
|
||||
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
|
||||
if (providerFailures > 0) {
|
||||
providerFailures--
|
||||
first = false
|
||||
throw new Error('Provider HTTP 429: retry later')
|
||||
}
|
||||
if (first) {
|
||||
first = false
|
||||
if (ending === 'incomplete') return
|
||||
if (ending === 'provider') throw new Error('Provider HTTP 429: retry later')
|
||||
if (ending !== 'cleanup') await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
|
||||
if (ending === 'throw') throw new DOMException('This operation was aborted', 'AbortError')
|
||||
}
|
||||
@@ -6700,10 +6758,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await rejected
|
||||
expect(database.listSupervisionResults()).toEqual([])
|
||||
expect(database.listSupervisionActivity()).toEqual([expect.objectContaining({ status: 'failed', error })])
|
||||
expect(releaseConversation).toHaveBeenCalledOnce()
|
||||
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 2 : 1)
|
||||
await invoke()
|
||||
expect(database.listSupervisionResults()).toHaveLength(1)
|
||||
expect(releaseConversation).toHaveBeenCalledTimes(2)
|
||||
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 3 : 2)
|
||||
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
|
||||
})
|
||||
|
||||
@@ -12150,6 +12208,131 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('routes Telegram settings and incoming messages through real channel handlers without modes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-telegram-'))
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(root)
|
||||
database.ensureChannelProjects(root)
|
||||
const routeConversation = vi.spyOn(database, 'getOrCreateRemoteConversation')
|
||||
const settingsStore = new ChannelSettingsStore(join(root, 'channels.json'), {
|
||||
isAvailable: () => true,
|
||||
encrypt: value => Buffer.from(value),
|
||||
decrypt: value => value.toString()
|
||||
}, {})
|
||||
const requests: AgentExecutionRequest[] = []
|
||||
const runtime = {
|
||||
capability: 'chat', supportsToolExecution: true,
|
||||
async *run(request: AgentExecutionRequest, _signal: AbortSignal, authorize?: unknown) {
|
||||
expect(authorize).toBeUndefined()
|
||||
requests.push(request)
|
||||
yield { requestId: request.requestId, type: 'text', delta: 'Telegram result' }
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
}
|
||||
}
|
||||
let delivered = false
|
||||
const replies: unknown[] = []
|
||||
electronMocks.fetch.mockImplementation(async (input, init) => {
|
||||
const method = String(input).split('/').at(-1)
|
||||
let result: unknown
|
||||
if (method === 'getMe') result = { id: 456, is_bot: true, username: 'ipc_test_bot' }
|
||||
else if (method === 'getWebhookInfo') result = { url: '' }
|
||||
else if (method === 'getUpdates') {
|
||||
result = delivered ? [] : [{
|
||||
update_id: 789,
|
||||
message: {
|
||||
message_id: 1, date: Math.floor(Date.now() / 1000),
|
||||
from: { id: 123, is_bot: false }, chat: { id: 123, type: 'private' },
|
||||
text: '/execute preserve this literal prefix'
|
||||
}
|
||||
}]
|
||||
delivered = true
|
||||
} else if (method === 'sendMessage') {
|
||||
replies.push(JSON.parse(String(init?.body)))
|
||||
result = { message_id: 2 }
|
||||
} else if (method === 'sendChatAction') result = true
|
||||
else throw new Error(`Unexpected Telegram method: ${method}`)
|
||||
return new Response(JSON.stringify({ ok: true, result }))
|
||||
})
|
||||
let harness: ReturnType<typeof createHarness> | undefined
|
||||
try {
|
||||
harness = createHarness(runtime, undefined, undefined, false, undefined, undefined,
|
||||
undefined, false, undefined, undefined, undefined, undefined, database,
|
||||
undefined, undefined, undefined, undefined, undefined, settingsStore)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
const get = electronMocks.handlers.get(ipcChannels.channelSettingsGet)!
|
||||
const apply = electronMocks.handlers.get(ipcChannels.channelSettingsApply)!
|
||||
const test = electronMocks.handlers.get(ipcChannels.channelSettingsTest)!
|
||||
const settings = {
|
||||
enabled: false, secret: { action: 'replace', value: '456:test-token' },
|
||||
allowedSenderIds: ['123'], allowGroupMessages: false
|
||||
}
|
||||
await expect(test(event, { channel: 'telegram', settings })).resolves.toEqual({
|
||||
channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
|
||||
})
|
||||
await expect(get(event)).resolves.toMatchObject({ telegram: { secretConfigured: false } })
|
||||
expect(delivered).toBe(false)
|
||||
expect(() => test(event, {
|
||||
channel: 'telegram', settings: { ...settings, allowedSenderIds: ['username'] }
|
||||
})).toThrow()
|
||||
expect(() => apply(event, { telegram: { ...settings, allowGroupMessages: true } })).toThrow()
|
||||
expect(() => test({ sender: {}, senderFrame: harness!.webContents.mainFrame }, {
|
||||
channel: 'telegram', settings
|
||||
})).toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
await expect(apply(event, { telegram: settings })).resolves.toMatchObject({
|
||||
telegram: { enabled: false, secretConfigured: true, allowedSenderIds: ['123'] }
|
||||
})
|
||||
await expect(test(event, { channel: 'telegram' })).resolves.toEqual({
|
||||
channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
|
||||
})
|
||||
await apply(event, { telegram: { ...settings, enabled: true, secret: { action: 'keep' } } })
|
||||
await vi.waitFor(() => expect(replies).toContainEqual({ chat_id: '123', text: 'Telegram result' }))
|
||||
expect(requests).toHaveLength(1)
|
||||
const request = requests[0]!
|
||||
const project = database.listProjects().find(item => item.channel === 'telegram')!
|
||||
expect(project).toMatchObject({ kind: 'channel', name: 'Telegram' })
|
||||
expect(routeConversation).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
projectId: project.id, channel: 'telegram', accountId: '456',
|
||||
externalConversationId: '456:123', conversationType: 'direct'
|
||||
}))
|
||||
expect(database.getConversation(request.conversationId!)).toMatchObject({
|
||||
projectId: project.id,
|
||||
remote: { channel: 'telegram', conversationType: 'direct' },
|
||||
messages: [
|
||||
expect.objectContaining({ role: 'user', content: '/execute preserve this literal prefix' }),
|
||||
expect.objectContaining({ role: 'assistant', content: 'Telegram result' })
|
||||
]
|
||||
})
|
||||
expect(database.getTask(request.requestId)).toMatchObject({
|
||||
projectId: project.id, conversationId: request.conversationId,
|
||||
origin: 'delegation', status: 'completed', instructions: '/execute preserve this literal prefix'
|
||||
})
|
||||
expect(request.prompt).toContain('/execute preserve this literal prefix')
|
||||
expect(request).not.toHaveProperty('workMode')
|
||||
expect(request).not.toHaveProperty('mode')
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
|
||||
channel: 'telegram', status: { state: 'starting' }
|
||||
})
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
|
||||
channel: 'telegram', status: { state: 'running' }
|
||||
})
|
||||
const history = database.getConversation(request.conversationId!)
|
||||
await expect(apply(event, { telegram: { ...settings, secret: { action: 'clear' } } })).resolves.toMatchObject({
|
||||
telegram: { enabled: false, secretConfigured: false, status: { state: 'disabled' } }
|
||||
})
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
|
||||
channel: 'telegram', status: { state: 'disabled' }
|
||||
})
|
||||
expect((await settingsStore.resolve('telegram')).secret).toBeUndefined()
|
||||
expect(database.getProject(project.id)).toEqual(project)
|
||||
expect(database.getConversation(request.conversationId!)).toEqual(history)
|
||||
} finally {
|
||||
await harness?.dispose()
|
||||
electronMocks.fetch.mockReset()
|
||||
database.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('bridges explanation-only channel requests without an authorizer', async () => {
|
||||
let received:
|
||||
| {
|
||||
@@ -12231,7 +12414,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
it.each([
|
||||
['weixin', '微信 ClawBot'],
|
||||
['wecom', '企业微信'],
|
||||
['dingtalk', '钉钉']
|
||||
['dingtalk', '钉钉'],
|
||||
['telegram', 'Telegram']
|
||||
] as const)(
|
||||
'grants enabled Magic Notes write tools to %s channel requests',
|
||||
async (channel, channelLabel) => {
|
||||
@@ -12341,6 +12525,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
)
|
||||
})
|
||||
expect(receivedRequest?.prompt).toContain('读取我的笔记')
|
||||
expect(receivedRequest).not.toHaveProperty('workMode')
|
||||
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
|
||||
'channel-notes-capability'
|
||||
)
|
||||
@@ -12451,8 +12636,20 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
throw new Error('Expected channel executor')
|
||||
}
|
||||
|
||||
await expect(
|
||||
executor(
|
||||
const outboxDatabase = new AssistantDatabase(':memory:')
|
||||
outboxDatabase.initialize('C:\\Workspace')
|
||||
const outbox = new SqliteChannelOutbox(outboxDatabase)
|
||||
let inbound!: ChannelInboundHandler
|
||||
const send = vi.fn<ChannelDriver['send']>(async () => undefined)
|
||||
const service = new ChannelService({
|
||||
channel: 'wecom',
|
||||
start: (handler) => { inbound = handler },
|
||||
send,
|
||||
stop: () => undefined
|
||||
}, executor, { allowedSenderIds: ['user-1'], outbox })
|
||||
try {
|
||||
await service.start()
|
||||
await inbound(
|
||||
{
|
||||
channel: 'wecom',
|
||||
eventId: 'event-generated-image',
|
||||
@@ -12462,34 +12659,38 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
text: '生成结果图',
|
||||
mentioned: false
|
||||
},
|
||||
new AbortController().signal
|
||||
() => undefined
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
attachments: [
|
||||
{
|
||||
name: '结果图.png',
|
||||
mimeType: 'image/png',
|
||||
size: image.byteLength,
|
||||
kind: 'image',
|
||||
dataBase64: image.toString('base64')
|
||||
}
|
||||
],
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
]
|
||||
})
|
||||
expect(
|
||||
harness.assistantDatabase.appendRemoteConversationMessage
|
||||
).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
await vi.waitFor(() => expect(send).toHaveBeenCalledOnce())
|
||||
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
attachments: [
|
||||
{
|
||||
name: '结果图.png',
|
||||
mimeType: 'image/png',
|
||||
size: image.byteLength,
|
||||
kind: 'image',
|
||||
dataBase64: image.toString('base64')
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
await harness.dispose()
|
||||
expect(send.mock.calls[0]?.[0]).not.toHaveProperty('artifactIds')
|
||||
expect(outbox.listUndelivered()).toEqual([])
|
||||
expect(
|
||||
harness.assistantDatabase.appendRemoteConversationMessage
|
||||
).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000499'
|
||||
]
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
await service.stop()
|
||||
outboxDatabase.close()
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a bounded result file only when the remote user explicitly requests one', async () => {
|
||||
|
||||
+20
-5
@@ -81,6 +81,7 @@ import {
|
||||
import {
|
||||
channelSettingsApplySchema,
|
||||
dingTalkChannelSettingsInputSchema,
|
||||
telegramChannelSettingsInputSchema,
|
||||
weComChannelSettingsInputSchema
|
||||
} from '../shared/channel-settings-contracts'
|
||||
import { applicationSettingsUpdateSchema, defaultSupervisorModelConcurrency } from '../shared/application-settings-contracts'
|
||||
@@ -346,6 +347,12 @@ const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [
|
||||
channel: z.literal('dingtalk'),
|
||||
settings: dingTalkChannelSettingsInputSchema.optional()
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
channel: z.literal('telegram'),
|
||||
settings: telegramChannelSettingsInputSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
@@ -3049,7 +3056,8 @@ export function registerIpcHandlers(
|
||||
status:
|
||||
result.status === 'completed' ? 'completed' : 'failed'
|
||||
})
|
||||
return result
|
||||
const { status, output, error, attachments } = result
|
||||
return { status, output, error, attachments }
|
||||
} finally {
|
||||
releaseRemoteContexts()
|
||||
}
|
||||
@@ -3057,6 +3065,11 @@ export function registerIpcHandlers(
|
||||
const channelManager = channelSettingsStore
|
||||
? new ChannelManager(channelSettingsStore, channelExecutor, {
|
||||
launchWechatSidecar,
|
||||
onStatusChanged: (change) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.channelStatusChanged, change)
|
||||
}
|
||||
},
|
||||
dedupStore: new SqliteChannelDedupStore(assistantDatabase),
|
||||
outbox: new SqliteChannelOutbox(assistantDatabase)
|
||||
})
|
||||
@@ -5078,6 +5091,9 @@ export function registerIpcHandlers(
|
||||
throw new Error('消息通道设置服务不可用')
|
||||
}
|
||||
const request = channelSettingsTestRequestSchema.parse(input)
|
||||
if (request.channel === 'telegram') {
|
||||
return channelManager.testConnection('telegram', request.settings)
|
||||
}
|
||||
return request.channel === 'wecom'
|
||||
? channelManager.testConnection('wecom', request.settings)
|
||||
: channelManager.testConnection('dingtalk', request.settings)
|
||||
@@ -7055,7 +7071,7 @@ export function registerIpcHandlers(
|
||||
registerHandler(ipcChannels.supervisionOverview, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const request = supervisionOverviewRequestSchema.parse(input ?? {})
|
||||
return assistantDatabase.listSupervisionResults(20, request.target, request.resultId)
|
||||
return assistantDatabase.listSupervisionResultsAsync(20, request.target, request.resultId)
|
||||
})
|
||||
registerHandler(ipcChannels.supervisionRun, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -7067,7 +7083,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
registerHandler(ipcChannels.supervisionGraph, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getSupervisionGraph(supervisionGraphRequestSchema.parse(input ?? {}))
|
||||
return assistantDatabase.getSupervisionGraphAsync(supervisionGraphRequestSchema.parse(input ?? {}))
|
||||
})
|
||||
registerHandler(ipcChannels.supervisionSource, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -7200,8 +7216,7 @@ export function registerIpcHandlers(
|
||||
assertTrustedSender(event, window)
|
||||
const { scope } = supervisionStoryListSchema.parse(input)
|
||||
if ((await applicationSettingsStore?.get())?.heartbeatEnabled !== true) return { stories: [], experiences: [], unassigned: 0, canUndo: false }
|
||||
const stories = assistantDatabase.supervisionStories()
|
||||
return { stories: stories.list(scope), experiences: assistantDatabase.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined), unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() }
|
||||
return assistantDatabase.getSupervisionStoriesAsync(scope)
|
||||
})
|
||||
registerHandler(ipcChannels.supervisionStoryAction, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
|
||||
@@ -66,6 +66,10 @@ if (kind === 'checkpoint') {
|
||||
handlers = {
|
||||
readStoryGraph: ([name, input, projectId], signal) => database.readStoryGraph(
|
||||
name as Parameters<AssistantDatabase['readStoryGraph']>[0], input, projectId as string | undefined, signal),
|
||||
supervisionOverview: ([limit, target, resultId]) => database.readSnapshot(() => database.listSupervisionResults(
|
||||
limit as number, target as Parameters<AssistantDatabase['listSupervisionResults']>[1], resultId as string | undefined)),
|
||||
supervisionGraph: ([input]) => database.readSnapshot(() => database.getSupervisionGraph(input as Parameters<AssistantDatabase['getSupervisionGraph']>[0])),
|
||||
supervisionStories: ([scope]) => database.readSnapshot(() => database.getSupervisionStories(scope as ReviewState['request']['scope'])),
|
||||
searchConversations: ([query]) => database.searchConversations(query as string),
|
||||
// Several statements each: read them from one snapshot.
|
||||
listConversationSummaries: ([detailIds]) => database.readSnapshot(() =>
|
||||
@@ -78,7 +82,8 @@ if (kind === 'checkpoint') {
|
||||
const scope = (request as ReviewState['request']).scope
|
||||
return database.readSnapshot(() => ({ summary: database.reviewSummary(scope, 'supervisor'), background: database.reviewBackground(scope) }))
|
||||
},
|
||||
reviewCandidates: ([request]) => database.listSupervisionCandidates(request as ReviewState['request'])
|
||||
reviewCandidates: ([request, batch]) => database.listSupervisionCandidates(request as ReviewState['request'],
|
||||
batch as Parameters<AssistantDatabase['listSupervisionCandidates']>[1])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -92,10 +92,12 @@ import type {
|
||||
} from '../shared/assistant-contracts'
|
||||
import type {
|
||||
ChannelConnectionTestResult,
|
||||
ChannelRuntimeStatusChange,
|
||||
ChannelSettingsApply,
|
||||
ChannelSettingsSnapshot,
|
||||
CredentialChannel,
|
||||
DingTalkChannelSettingsInput,
|
||||
TelegramChannelSettingsInput,
|
||||
WeComChannelSettingsInput
|
||||
} from '../shared/channel-settings-contracts'
|
||||
import type {
|
||||
@@ -626,6 +628,14 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
channels: {
|
||||
onStatusChanged: (listener) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
change: ChannelRuntimeStatusChange
|
||||
): void => listener(change)
|
||||
ipcRenderer.on(ipcChannels.channelStatusChanged, handler)
|
||||
return () => ipcRenderer.removeListener(ipcChannels.channelStatusChanged, handler)
|
||||
},
|
||||
getSnapshot: () =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.channelSettingsGet
|
||||
@@ -637,7 +647,7 @@ const desktopApi: DesktopApi = {
|
||||
) as Promise<ChannelSettingsSnapshot>,
|
||||
testConnection: (
|
||||
channel: CredentialChannel,
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
|
||||
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
|
||||
) =>
|
||||
ipcRenderer.invoke(ipcChannels.channelSettingsTest, {
|
||||
channel,
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi } from '../shared/contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
contextBridge: { exposeInMainWorld: vi.fn() },
|
||||
ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() },
|
||||
webUtils: {}
|
||||
}))
|
||||
vi.mock('electron', () => electron)
|
||||
|
||||
describe('sandboxed preload', () => {
|
||||
it('forwards status deltas without the Electron event and removes the exact subscription', async () => {
|
||||
await import('./index')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = api.channels!.onStatusChanged(listener)
|
||||
const [channel, handler] = electron.ipcRenderer.on.mock.calls.at(-1)!
|
||||
expect(channel).toBe(ipcChannels.channelStatusChanged)
|
||||
const change = { channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } }
|
||||
handler({ sender: 'main' }, change)
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith(change)
|
||||
unsubscribe()
|
||||
expect(electron.ipcRenderer.removeListener).toHaveBeenCalledExactlyOnceWith(channel, handler)
|
||||
})
|
||||
|
||||
it('forwards Telegram settings and draft or saved connection tests through channel IPC', async () => {
|
||||
await import('./index')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
||||
const settings = {
|
||||
enabled: false, secret: { action: 'replace' as const, value: '456:test-token' },
|
||||
allowedSenderIds: ['123'], allowGroupMessages: false as const
|
||||
}
|
||||
await api.channels!.getSnapshot()
|
||||
await api.channels!.apply({ telegram: settings })
|
||||
await api.channels!.testConnection('telegram', settings)
|
||||
await api.channels!.testConnection('telegram')
|
||||
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
|
||||
[ipcChannels.channelSettingsGet],
|
||||
[ipcChannels.channelSettingsApply, { telegram: settings }],
|
||||
[ipcChannels.channelSettingsTest, { channel: 'telegram', settings }],
|
||||
[ipcChannels.channelSettingsTest, { channel: 'telegram', settings: undefined }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not import Node built-ins unavailable in Electron sandbox', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
||||
|
||||
@@ -9951,13 +9951,14 @@ describe("App", () => {
|
||||
["weixin", "微信 ClawBot"],
|
||||
["wecom", "企业微信"],
|
||||
["dingtalk", "钉钉"],
|
||||
["telegram", "Telegram"],
|
||||
].map(([channel, name], index) => ({
|
||||
...project,
|
||||
id: `00000000-0000-4000-8000-00000000020${index + 1}`,
|
||||
name: name!,
|
||||
description: `${name}通道项目`,
|
||||
kind: "channel" as const,
|
||||
channel: channel as "weixin" | "wecom" | "dingtalk",
|
||||
channel: channel as AssistantProject["channel"],
|
||||
runtimeSelection: {
|
||||
provider: "model" as const,
|
||||
profileId: modelProfileId,
|
||||
@@ -9991,7 +9992,17 @@ describe("App", () => {
|
||||
},
|
||||
);
|
||||
api.channels = {
|
||||
onStatusChanged: vi.fn(() => () => undefined),
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
telegram: {
|
||||
enabled: false,
|
||||
secretConfigured: false,
|
||||
source: "none" as const,
|
||||
readOnly: false,
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false as const,
|
||||
status: { state: "disabled" as const },
|
||||
},
|
||||
weixin: {
|
||||
enabled: false,
|
||||
bindingConfigured: false,
|
||||
@@ -14099,6 +14110,25 @@ describe("App", () => {
|
||||
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reload artifacts on first or warm Supervisor entry", async () => {
|
||||
render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(api.artifacts.list).toHaveBeenCalled();
|
||||
expect(api.heartbeats.history).toHaveBeenCalled();
|
||||
});
|
||||
const artifactReads = vi.mocked(api.artifacts.list).mock.calls.length;
|
||||
const historyReads = vi.mocked(api.heartbeats.history).mock.calls.length;
|
||||
fireEvent.click(await screen.findByRole("button", { name: "监督者" }));
|
||||
await screen.findByRole("heading", { name: "监督者" });
|
||||
await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 1));
|
||||
expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads);
|
||||
const navigation = within(screen.getByRole("navigation", { name: "主导航" }));
|
||||
fireEvent.click(navigation.getByRole("button", { name: "对话" }));
|
||||
fireEvent.click(navigation.getByRole("button", { name: "监督者" }));
|
||||
await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 2));
|
||||
expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads);
|
||||
});
|
||||
|
||||
it("counts only pending supervisor suggestions in the navigation badge", async () => {
|
||||
vi.mocked(api.supervision.suggestions).mockResolvedValue([
|
||||
{ id: "00000000-0000-4000-8000-000000000901", resultId: null, heartbeatRunId: null, scope: { kind: "global" },
|
||||
|
||||
@@ -171,7 +171,6 @@ import {
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import {
|
||||
refreshArtifacts,
|
||||
refreshTasks,
|
||||
useArtifactHydration,
|
||||
useInitialArtifactSync,
|
||||
@@ -764,7 +763,7 @@ function isConversation(value: unknown): value is Conversation {
|
||||
(item.remote === undefined ||
|
||||
(typeof item.remote === "object" &&
|
||||
item.remote !== null &&
|
||||
["weixin", "wecom", "dingtalk"].includes(
|
||||
["weixin", "wecom", "dingtalk", "telegram"].includes(
|
||||
String((item.remote as Record<string, unknown>).channel),
|
||||
))) &&
|
||||
typeof item.title === "string" &&
|
||||
@@ -3878,8 +3877,8 @@ function App(): React.JSX.Element {
|
||||
}, [loadHeartbeats, projects.length]);
|
||||
|
||||
const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
|
||||
await Promise.all([refreshArtifacts(taskStore), refreshHeartbeats()]);
|
||||
}, [refreshHeartbeats, taskStore]);
|
||||
await refreshHeartbeats();
|
||||
}, [refreshHeartbeats]);
|
||||
|
||||
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
|
||||
setHeartbeatLoading(true);
|
||||
|
||||
@@ -25,6 +25,10 @@ import type { ProjectChannel } from '../../shared/assistant-contracts'
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
const channelMarks = {
|
||||
telegram: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M22 2 2 10l7 3 3 9 10-20ZM5.6 10l12-4.8-8 6.1L5.6 10Zm5.2 3 7.4-6.2-6 11.9-1.4-5.7Z'
|
||||
},
|
||||
weixin: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
} from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
|
||||
import type { ChannelRuntimeStatusChange, ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
|
||||
import {
|
||||
defaultRuntimeSettings,
|
||||
type DesktopApi,
|
||||
@@ -71,6 +72,15 @@ const runtimeSettings: RuntimeSettings = {
|
||||
}
|
||||
|
||||
const snapshot: ChannelSettingsSnapshot = {
|
||||
telegram: {
|
||||
enabled: false,
|
||||
secretConfigured: false,
|
||||
source: 'none',
|
||||
readOnly: false,
|
||||
allowedSenderIds: [],
|
||||
allowGroupMessages: false,
|
||||
status: { state: 'disabled' }
|
||||
},
|
||||
weixin: {
|
||||
enabled: false,
|
||||
bindingConfigured: false,
|
||||
@@ -102,7 +112,8 @@ const snapshot: ChannelSettingsSnapshot = {
|
||||
const projects: AssistantProject[] = [
|
||||
['weixin', '微信 ClawBot'],
|
||||
['wecom', '企业微信'],
|
||||
['dingtalk', '钉钉']
|
||||
['dingtalk', '钉钉'],
|
||||
['telegram', 'Telegram']
|
||||
].map(([channel, name], index) => ({
|
||||
id: `00000000-0000-4000-8000-00000000000${index + 1}`,
|
||||
name: name!,
|
||||
@@ -114,7 +125,7 @@ const projects: AssistantProject[] = [
|
||||
},
|
||||
runtimeSelection: { provider: 'model' },
|
||||
kind: 'channel',
|
||||
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
|
||||
channel: channel as AssistantProject['channel'],
|
||||
status: 'active',
|
||||
createdAt: '2026-08-04T00:00:00.000Z',
|
||||
updatedAt: '2026-08-04T00:00:00.000Z'
|
||||
@@ -167,6 +178,256 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ChannelSettingsSection', () => {
|
||||
it.each(['error', 'stopped', 'running', 'starting', 'disabled'] as const)(
|
||||
'only reapplies unchanged enabled Telegram settings when pushed state is %s and needs recovery',
|
||||
async (state) => {
|
||||
const configured: ChannelSettingsSnapshot = {
|
||||
...snapshot,
|
||||
telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted', status: { state: 'running' } }
|
||||
}
|
||||
let receive!: (change: ChannelRuntimeStatusChange) => void
|
||||
const apply = vi.fn(async () => configured)
|
||||
const onNotify = vi.fn()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: { ...bindingApi(), getSnapshot: async () => configured, apply,
|
||||
onStatusChanged: (listener: typeof receive) => { receive = listener; return () => undefined } },
|
||||
settings: settingsApi(), projects: { update: vi.fn(async () => projects[0]) }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
renderChannelSettings({ initialChannel: 'telegram', onNotify })
|
||||
await screen.findByLabelText('TelegramBot Token')
|
||||
act(() => receive({ channel: 'telegram', status: { state } }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalled())
|
||||
if (state === 'error' || state === 'stopped') {
|
||||
expect(apply).toHaveBeenCalledWith({ telegram: {
|
||||
enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
} })
|
||||
} else {
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => {
|
||||
let receive!: (change: ChannelRuntimeStatusChange) => void
|
||||
const unsubscribe = vi.fn()
|
||||
const onStatusChanged = vi.fn((listener: typeof receive) => {
|
||||
receive = listener
|
||||
return unsubscribe
|
||||
})
|
||||
const getSnapshot = vi.fn(async () => {
|
||||
receive({ channel: 'telegram', status: { state: 'running' } })
|
||||
return { ...snapshot, telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, status: { state: 'starting' } } }
|
||||
})
|
||||
const apply = vi.fn()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: { ...bindingApi(), onStatusChanged, getSnapshot, apply, testConnection: vi.fn() },
|
||||
settings: settingsApi(), projects: { update: vi.fn() }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
const { unmount } = renderChannelSettings({ initialChannel: 'telegram' })
|
||||
const token = await screen.findByLabelText('TelegramBot Token')
|
||||
expect(screen.getByText('已连接')).toBeInTheDocument()
|
||||
expect(screen.queryByText('正在连接')).not.toBeInTheDocument()
|
||||
fireEvent.change(token, { target: { value: 'unsaved-token-placeholder' } })
|
||||
const senders = screen.getByLabelText('Telegram允许的发送者 ID')
|
||||
fireEvent.change(senders, { target: { value: '12345\n67890' } })
|
||||
act(() => receive({ channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } }))
|
||||
expect(screen.getByText('连接失败')).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Network unavailable')
|
||||
act(() => receive({ channel: 'telegram', status: { state: 'starting' } }))
|
||||
expect(screen.getByText('正在连接')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Network unavailable')).not.toBeInTheDocument()
|
||||
act(() => receive({ channel: 'telegram', status: { state: 'running' } }))
|
||||
expect(screen.getByText('已连接')).toBeInTheDocument()
|
||||
expect(token).toHaveValue('unsaved-token-placeholder')
|
||||
expect(senders).toHaveValue('12345\n67890')
|
||||
expect(getSnapshot).toHaveBeenCalledOnce()
|
||||
expect(onStatusChanged).toHaveBeenCalledOnce()
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tests and saves Telegram credentials, keeps them on whitelist edits, and clears them explicitly', async () => {
|
||||
const configured = {
|
||||
...snapshot,
|
||||
telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted' as const }
|
||||
}
|
||||
const apply = vi.fn()
|
||||
.mockResolvedValueOnce(configured)
|
||||
.mockResolvedValueOnce({ ...configured, telegram: { ...configured.telegram, allowedSenderIds: ['12345', '67890'] } })
|
||||
.mockResolvedValueOnce(snapshot)
|
||||
const testConnection = vi.fn(async () => ({ channel: 'telegram', ok: true, botUsername: 'goodbuddy_test_bot' }))
|
||||
const updateProject = vi.fn(async (id: string, input: ProjectCreateInput) => ({ ...projects.find((p) => p.id === id)!, ...input }))
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: { ...bindingApi(), getSnapshot: vi.fn(async () => snapshot), apply, testConnection },
|
||||
settings: settingsApi(),
|
||||
projects: { update: updateProject }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
const onNotify = vi.fn()
|
||||
renderChannelSettings({ initialChannel: 'telegram', onNotify })
|
||||
const token = await screen.findByLabelText('TelegramBot Token')
|
||||
expect(token).toHaveAttribute('type', 'password')
|
||||
expect(screen.getByRole('link', { name: '打开官方 BotFather' })).toHaveAttribute('href', 'https://t.me/BotFather')
|
||||
expect(screen.getByText(/尚未授权用户/u)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('switch', { name: /群聊/u })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/机器人 ID/u)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('group', { name: /默认模式/u })).not.toBeInTheDocument()
|
||||
fireEvent.change(token, { target: { value: 'test-token-placeholder' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
|
||||
await waitFor(() => {
|
||||
expect(testConnection).toHaveBeenCalledWith('telegram', {
|
||||
enabled: false, secret: { action: 'replace', value: 'test-token-placeholder' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
})
|
||||
expect(screen.getByRole('link', { name: '@goodbuddy_test_bot' })).toHaveAttribute('href', 'https://t.me/goodbuddy_test_bot')
|
||||
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
|
||||
})
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('switch', { name: '启用Telegram通道' })).not.toBeChecked()
|
||||
expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'Telegram 凭据验证成功;测试不会启用消息接收。' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: '启用Telegram通道' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
await waitFor(() => {
|
||||
expect(apply).toHaveBeenLastCalledWith({ telegram: {
|
||||
enabled: true, secret: { action: 'replace', value: 'test-token-placeholder' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
} })
|
||||
expect(token).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Telegram允许的发送者 ID'), { target: { value: '12345,67890\n12345' } })
|
||||
fireEvent.change(screen.getByLabelText('Telegram 默认工作目录'), { target: { value: 'C:\\TelegramWorkspace' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
await waitFor(() => {
|
||||
expect(apply).toHaveBeenLastCalledWith({ telegram: {
|
||||
enabled: true, secret: { action: 'keep' }, allowedSenderIds: ['12345', '67890'], allowGroupMessages: false
|
||||
} })
|
||||
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
|
||||
})
|
||||
expect(updateProject).toHaveBeenCalledWith(projects[3]!.id, expect.objectContaining({ rootPath: 'C:\\TelegramWorkspace' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /保存时清除 Token/u }))
|
||||
expect(screen.queryByRole('link', { name: '@goodbuddy_test_bot' })).not.toBeInTheDocument()
|
||||
expect(token).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '测试Telegram连接' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
await waitFor(() => {
|
||||
expect(apply).toHaveBeenLastCalledWith({ telegram: {
|
||||
enabled: false, secret: { action: 'clear' }, allowedSenderIds: ['12345', '67890'], allowGroupMessages: false
|
||||
} })
|
||||
expect(screen.queryByRole('checkbox', { name: /保存时清除 Token/u })).not.toBeInTheDocument()
|
||||
expect(token).toHaveValue('')
|
||||
expect(token).toBeEnabled()
|
||||
expect(screen.getByRole('switch', { name: '启用Telegram通道' })).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
it('validates Telegram IDs and retains the draft after test and save failures', async () => {
|
||||
const apply = vi.fn().mockRejectedValue(new Error('Save failed'))
|
||||
const testConnection = vi.fn().mockResolvedValue({ channel: 'telegram', ok: false, error: 'Webhook in use' })
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: { ...bindingApi(), getSnapshot: vi.fn(async () => snapshot), apply, testConnection },
|
||||
settings: settingsApi(), projects: { update: vi.fn(async () => projects[3]) }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
renderChannelSettings({ initialChannel: 'telegram' })
|
||||
const token = await screen.findByLabelText('TelegramBot Token')
|
||||
fireEvent.change(token, { target: { value: 'test-token-placeholder' } })
|
||||
const senders = screen.getByLabelText('Telegram允许的发送者 ID')
|
||||
fireEvent.change(senders, { target: { value: '@username' } })
|
||||
expect(senders).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(senders).toHaveAccessibleDescription('请填写正整数用户 ID,最多 100 个,每个不超过 256 位,不要填写 @用户名。')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '测试Telegram连接' })).toBeDisabled()
|
||||
fireEvent.change(senders, { target: { value: '12345' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Webhook in use')).toBeInTheDocument()
|
||||
expect(token).toBeEnabled()
|
||||
expect(token).toHaveValue('test-token-placeholder')
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Save failed')).toBeInTheDocument()
|
||||
expect(token).toHaveValue('test-token-placeholder')
|
||||
expect(senders).toHaveValue('12345')
|
||||
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('tests saved Telegram Tokens without replacement and locks edits until the result arrives', async () => {
|
||||
let resolveTest!: (value: { channel: 'telegram'; ok: boolean; botUsername: string }) => void
|
||||
const testConnection = vi.fn(() => new Promise((resolve) => { resolveTest = resolve }))
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...bindingApi(),
|
||||
getSnapshot: vi.fn(async () => ({ ...snapshot, telegram: { ...snapshot.telegram, secretConfigured: true, source: 'encrypted' } })),
|
||||
apply: vi.fn(), testConnection
|
||||
},
|
||||
settings: settingsApi(), projects: { update: vi.fn() }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
renderChannelSettings({ initialChannel: 'telegram' })
|
||||
const token = await screen.findByLabelText('TelegramBot Token')
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
|
||||
expect(testConnection).toHaveBeenCalledWith('telegram', {
|
||||
enabled: false, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
|
||||
})
|
||||
expect(token).toBeDisabled()
|
||||
expect(screen.getByRole('checkbox', { name: /保存时清除 Token/u })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeDisabled()
|
||||
await act(async () => resolveTest({ channel: 'telegram', ok: true, botUsername: 'saved_test_bot' }))
|
||||
await waitFor(() => {
|
||||
expect(token).toBeEnabled()
|
||||
expect(screen.getByRole('link', { name: '@saved_test_bot' })).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.change(token, { target: { value: 'replacement-placeholder' } })
|
||||
expect(screen.queryByRole('link', { name: '@saved_test_bot' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders English Telegram setup and tests read-only environment credentials', async () => {
|
||||
const testConnection = vi.fn(async () => ({ channel: 'telegram', ok: true }))
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...bindingApi(),
|
||||
getSnapshot: vi.fn(async () => ({ ...snapshot, telegram: { ...snapshot.telegram, secretConfigured: true, readOnly: true, source: 'environment' } })),
|
||||
apply: vi.fn(), testConnection
|
||||
},
|
||||
settings: settingsApi(), projects: { update: vi.fn() }
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
await i18n.changeLanguage('en-US')
|
||||
renderChannelSettings({ initialChannel: 'telegram' })
|
||||
expect(await screen.findByLabelText('Telegram Bot Token')).toBeDisabled()
|
||||
expect(screen.getByRole('switch', { name: 'Enable the Telegram channel' })).toBeDisabled()
|
||||
expect(screen.getByLabelText('Telegram allowed sender IDs')).toBeDisabled()
|
||||
expect(screen.getByText(/Send \/newbot to BotFather/u)).toHaveTextContent('/whoami')
|
||||
expect(screen.getByText(/No users authorized/u)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connection and offline messages' }))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('operating system proxy')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Telegram connection' }))
|
||||
await waitFor(() => {
|
||||
expect(testConnection).toHaveBeenCalledWith('telegram', undefined)
|
||||
expect(screen.getByRole('button', { name: 'Test Telegram connection' })).toBeEnabled()
|
||||
})
|
||||
expect(screen.queryByRole('link', { name: /^@/u })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves editable channel settings without returning stored secrets', async () => {
|
||||
const updateProject = vi.fn(async (
|
||||
projectId: string,
|
||||
@@ -545,7 +806,7 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('presents the three channel configurations as keyboard tabs', async () => {
|
||||
it('presents the four channel configurations as keyboard tabs', async () => {
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -581,6 +842,7 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(wecomTab).toHaveAttribute('tabindex', '-1')
|
||||
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
|
||||
expect(within(tablist).getByRole('tab', { name: 'Telegram' })).toHaveAttribute('tabindex', '-1')
|
||||
expect(screen.getByText('项目设置')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('与左上角当前通道项目的设置保持同步。')
|
||||
@@ -601,6 +863,9 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '启用企业微信通道' })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.keyDown(wecomTab, { key: 'End' })
|
||||
expect(within(tablist).getByRole('tab', { name: 'Telegram' })).toHaveFocus()
|
||||
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'channel-settings-tab-telegram')
|
||||
})
|
||||
|
||||
it('opens the requested channel configuration', async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user