fix: reduce supervisor stalls and preserve task delivery
Load Supervisor graphs only when opened, preserve source details on demand, reuse indexed story digests and window selection lists without losing keyboard or focus behavior. Route heavy UI reads through the existing worker, batch scoped queries and construct each publication snapshot once. Keep remote cancellation responsive during model-route cleanup, remove late routes and retain reported usage after response-cap cancellation. Preserve Telegram pending replies through polling conflicts, show delivery errors and honor the final rate-limit cooldown. Schema 62 adds lookup indexes without rewriting user content; older clients cannot open upgraded data. Large review publication still blocks Main and remains documented follow-up work. Record the future storage-service direction without performing the architecture migration. Validation: exact staged code passed isolated full tests (6212 passed, 87 skipped), typecheck and lint. Three-round source performance comparisons, SQLite migration equivalence, native Electron interaction checks and current-source Linux x64 Host validation completed; eight external text-model requests. Release note: Supervisor opens reviews without loading the entire graph, keeps long selection lists responsive and preserves details and keyboard navigation. Remote cancellation and Telegram reply recovery are more reliable; remote fixes require an Agent build containing this change.
This commit is contained in:
@@ -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),本轮不实施全面迁移。
|
||||
|
||||
@@ -297,6 +297,12 @@ Agent 当前 Runtime installation + 兼容配置
|
||||
发送 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 的权限请求,且原生子代理只继承父
|
||||
|
||||
@@ -24,6 +24,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 任务。此归属用于区分本任务的改动,未替其他负责方审定或提交其代码。
|
||||
@@ -4,6 +4,46 @@
|
||||
|
||||
当前记录以已验证生产行为为准。监督者尚未覆盖全部 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-S6。`runSupervisionModel` 已接入一次可取消的临时失败重试,覆盖监督者各模型阶段;错误分类、清理及每次尝试的超时规则见[技术设计](./technical-design.md#模型阶段超时与调度)。直连文本请求保留 HTTP status,并交出失败流中已报告的用量。OpenCode/Continue、直连传输重试和叶子/导航输出重试未改。监督者仍在桌面 Main 直连模型,不经过远程 Agent 或模型桥。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 产品设计 | [监督者应用产品设计](./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 当作生产数据模型。
|
||||
|
||||
@@ -33,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 与项目。
|
||||
|
||||
@@ -45,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 方法。
|
||||
@@ -61,7 +75,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、
|
||||
|
||||
`runSupervisionModel` 为提取、导航、故事、经验和建议共用一次临时错误重试,错误范围见[逻辑规则](./logic-design.md#监督者模型选择)。每次尝试使用新的 request/conversation ID 和隐藏任务,正文缓冲单独创建;用量按各次任务保存。失败后先清理计时器并释放临时会话,再等待;等待继续持有监督池槽位,受父 signal 和池取消控制,结束或失败后释放槽位。`error` 事件的 `status: cancelled` 优先于错误文字;抛出的结构化错误检查 status、statusCode 和 cause,字符串只识别明确的 HTTP 状态或已知网络/流中断格式。
|
||||
|
||||
直连文本请求保留 HTTP 错误的 status,并在响应流失败前交出已收到的用量;没有提供方用量时不估算补记。重试决策只在监督包装层,直连传输原有重试、OpenCode 和 Continue 策略不变。429 使用同一 500ms 等待,不新增 Retry-After、UI 或配置项。
|
||||
直连文本请求保留 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 控制。
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
工作回顾与故事线图谱在主页签下方共用回顾上下文栏。历史选择器最多占 280px,收起时只显示生成日期时间,随后是“刷新”和“新回顾”,不在右侧重复范围和覆盖区间。展开复用共享锚定单选菜单,每项显示生成时间及该结果自身的真实范围、覆盖起止时间,长说明换行;当前项带选中标记,触发按钮的无障碍描述保留所选结果的完整上下文。即使只有一条结果也显示选择器。切换页签和图谱模式时,这一栏的位置、内容和选择保持一致,空态及读取失败时仍可访问。
|
||||
|
||||
首次打开工作回顾只读取回顾及其范围的故事摘要,不等待图谱。进入故事线图谱时才读取所选结果的图谱;读取期间在图谱页显示加载状态,返回工作回顾仍可阅读及选择历史。工作回顾与图谱共用已读的故事数据,普通页签切换不重复读取。当前结果的图谱在切换页签时保留;切换历史结果、显式刷新、实体或关系修改后的刷新会使旧图谱失效。来源列表只需标题和时间,点击来源才读取完整正文。
|
||||
|
||||
点击“新回顾”才展开范围、时间、发起按钮和重新整理菜单。表单位于上下文栏下方,以次级表面区分新请求与已选结果;再次点击收起并保留输入。工作回顾正文铺满页面壳层,不另设阅读宽度上限。容器宽于 860px 且有辅助内容时,左侧约占三分之二,显示工作总结和本次变化;右侧显示未解决事项、故事进展和经验,以弱分隔线分区。没有辅助内容时只保留全宽正文。窄屏按正文、辅助内容顺序排列为单列。摘要保留原有段落和换行,故事摘要保留“显示更多”及进入图谱的选择。
|
||||
|
||||
新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用。手动发起、完成、暂停、取消、无变化和失败统一使用应用 Toast;重复点击发起提示已有运行,不新建或排队。同一回顾操作的通知互相替换,关闭通知不暂停或取消执行。成功和信息沿用共享自动消失时间,错误由用户关闭。页面不保留操作状态条或错误横幅,加载历史结果、切换页签和重新进入页面不触发回顾通知。
|
||||
@@ -94,9 +96,9 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
|
||||
|
||||
没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。
|
||||
|
||||
选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
|
||||
选择历史结果后,历史菜单显示该结果的回顾区间和生成时间,旧图谱和来源详情清空。在图谱页选择历史时立即读取对应成员和内容;在工作回顾页选择时,图谱读取留到下次进入图谱页。迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。
|
||||
|
||||
切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。新图谱读取完成后,历史选择、刷新和详情操作恢复可用;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。
|
||||
切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。工作回顾在回顾读取完成后恢复历史选择和刷新;图谱页等待对应图谱读取完成后恢复操作。图谱失败只在图谱页提示并提供重试,不阻止阅读已读回顾;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。
|
||||
|
||||
手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。
|
||||
|
||||
@@ -131,6 +133,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、点击或聚焦外部关闭。平铺可见数量仍在画布角落;螺旋左下角不显示圈长及圈数,这些信息收进图例。窗口尺寸及上方表单展开、收起后重新计算可用高度。
|
||||
|
||||
@@ -371,6 +371,12 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创
|
||||
阶段。实现规则见[共享进程与 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:连接、查询、启动、取消、关闭和重连。数据面只负责
|
||||
|
||||
@@ -138,6 +138,8 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
|
||||
|
||||
已生成并保存的结果沿用现有发件箱有限重试,发送失败与任务执行失败分别记录。补发回复不重新调用模型或重跑任务。用户阻止 Bot 等不可恢复错误应停止无效重试。
|
||||
|
||||
已有 webhook 或另一实例占用轮询时,已确认 Bot 身份且目标匹配的待发回复仍可发送;接收冲突本身不能永久终止这些回复。发送内部重试达到上限后,发件箱的下一次尝试仍须遵守最近一次 429 响应的等待时间,停用可取消等待。
|
||||
|
||||
同一驱动实例内且分段进度仍在缓存时,多段回复从未发送部分继续。进度不跨重启持久化,重启或缓存淘汰后补发可能重复已发送段落;网络中断造成平台接收结果不明时也可能重复。首版不承诺平台级发送幂等。
|
||||
|
||||
### FR-10 启停与历史保留
|
||||
@@ -162,6 +164,8 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
|
||||
|
||||
状态和错误推送只更新状态区域,不覆盖尚未保存的 Token 或白名单草稿。解决轮询冲突后可重新保存当前配置以启动连接;单独测试连接成功不改变接收状态。
|
||||
|
||||
接收正常而结果发送失败时,保留已连接状态并显示脱敏的发送错误。后续健康轮询不清除发送提示,投递成功后清除;接收也发生错误时优先展示接收原因,恢复后保留尚未清除的发送提示。
|
||||
|
||||
状态通过文字表达,不能只靠颜色。错误保留可操作原因,如 Token 无效、网络不可达、webhook 占用或另一实例正在轮询。测试成功、发送失败和模型失败使用各自准确文案,避免统一显示“连接失败”。
|
||||
|
||||
界面沿用 [UI-DESIGN.md](../../../UI-DESIGN.md) 的控件、通知、键盘和可访问性规则,不新增独立视觉体系。
|
||||
|
||||
@@ -55,6 +55,32 @@ npm test -- tests/telegram-channel.electron.test.ts
|
||||
|
||||
补充检查:钉钉错误码断言加入后,单独复跑 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 次,无外部模型调用。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
设置页经生产 Preload 和 IPC 调用 `ChannelManager`,由 `ChannelSettingsStore` 保存配置。Manager 按需加载 `TelegramChannelDriver`,向其注入 Electron `net.fetch`;驱动默认网络实现也使用 `net.fetch`。请求走 Electron 网络栈及系统代理,不依赖 Node 的 `HTTPS_PROXY` 配置,不使用 grammY 或 Telegraf。
|
||||
|
||||
驱动先调用 `getMe` 与 `getWebhookInfo`,拒绝已有 webhook,不删除外部配置。测试连接只做这两项检查,不启动轮询。启用后使用 `getUpdates`,`timeout=30`、`limit=100`、`allowed_updates=['message']`;普通请求超时 15 秒,轮询请求超时 40 秒。临时错误以 1~30 秒退避重连,429 按 `retry_after` 等待;其他 4xx 停止轮询并报告错误。
|
||||
驱动先调用 `getMe` 与 `getWebhookInfo`,已有 webhook 时拒绝启动轮询,不删除外部配置。`getMe` 验证的 Bot 身份独立保留;启动 webhook 冲突与运行中的轮询冲突均不阻止身份匹配的已保存回复发送,也不因此终止其发件箱记录。测试连接只做这两项检查,不启动轮询或改写运行中的身份。启用后使用 `getUpdates`,`timeout=30`、`limit=100`、`allowed_updates=['message']`;普通请求超时 15 秒,轮询请求超时 40 秒。临时错误以 1~30 秒退避重连,429 按 `retry_after` 等待;其他 4xx 停止轮询并报告错误。
|
||||
|
||||
## 身份、执行与保存
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
生成图片后,executor 先把 `artifactIds` 写入桌面会话,再向 `ChannelService` 返回通道契约中的状态、正文、错误和附件。图片字节保留在附件中;桌面成果 ID 不进入严格校验的通道结果,避免成功任务被误报为“无效结果”。该转换位于本地和远程 Runtime 事件处理之后,不改变 Runtime 或 Agent 协议。
|
||||
|
||||
驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送的 429 最多等待重试 3 次,仍失败则交回发件箱处理。跨重启补发与不确定网络结果允许产生重复段落。
|
||||
驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送遇到 429 最多额外尝试 3 次;最后一次仍被限流时,先等待该响应的 `retry_after`,再将失败交回发件箱,避免服务层提前重发。等待沿用调用方与服务生命周期的取消信号;停止期间不增加失败计数。跨重启补发与不确定网络结果允许产生重复段落。
|
||||
|
||||
停用会中止轮询、发送等待、活动执行与排队工作,等待活动操作结束后报告停止。清除凭据时设置页同时提交 `enabled=false`;底层拒绝启用但无 Token 的配置。Token 替换先验证,失败不停止旧服务。项目与历史不因停用或清除凭据删除。
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
|
||||
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 请求。
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
# 进度与证据
|
||||
|
||||
## 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)已修复。
|
||||
|
||||
@@ -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. 已知问题和下一步
|
||||
|
||||
|
||||
@@ -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 延迟达到批次目标;取消、
|
||||
崩溃恢复和迁移语义不回退。
|
||||
|
||||
|
||||
@@ -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,35 @@ 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)
|
||||
@@ -88,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() }))
|
||||
}
|
||||
|
||||
@@ -32,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,
|
||||
@@ -237,7 +237,7 @@ export class AgentOwnedAcpPrompt {
|
||||
}))
|
||||
}
|
||||
this.#hasMcpServers = mcpServers.length > 0
|
||||
await whileStarting(Promise.resolve(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
|
||||
|
||||
@@ -518,6 +518,92 @@ 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 })
|
||||
|
||||
@@ -1502,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,
|
||||
@@ -1897,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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
@@ -2936,7 +2938,7 @@ describe('AssistantDatabase', () => {
|
||||
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(61)
|
||||
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'
|
||||
|
||||
@@ -136,7 +136,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 61
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62
|
||||
|
||||
function withoutHistoricalWorkMode(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
@@ -9144,6 +9144,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()]))
|
||||
@@ -9222,15 +9223,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) {
|
||||
@@ -9242,12 +9236,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)
|
||||
@@ -9273,6 +9270,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
|
||||
@@ -9325,7 +9352,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)
|
||||
}
|
||||
@@ -9352,9 +9379,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') }
|
||||
@@ -12334,6 +12361,17 @@ export class AssistantDatabase {
|
||||
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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -229,3 +229,94 @@ it('retries an actual interrupted direct-model stream and retains its reported u
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -73,22 +73,37 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
}, 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 {
|
||||
// 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 })
|
||||
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()
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -102,7 +117,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
database.updateTaskStatus(requestId, 'completed')
|
||||
return output
|
||||
} catch (error) {
|
||||
const failure = timeoutError ?? (modelSignal.aborted ? modelSignal.reason : 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) : '监督者模型调用失败')
|
||||
@@ -110,7 +125,10 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
|
||||
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()
|
||||
|
||||
@@ -389,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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -203,6 +203,12 @@ describe('ChannelManager', () => {
|
||||
})
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -488,12 +488,16 @@ export class ChannelManager {
|
||||
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,
|
||||
...(status.lastError ? {
|
||||
lastError: redactManagerError(status.lastError, [
|
||||
...(lastError ? {
|
||||
lastError: redactManagerError(lastError, [
|
||||
settings.channel === 'weixin' ? settings.token : settings.secret
|
||||
])
|
||||
} : {})
|
||||
@@ -506,7 +510,11 @@ export class ChannelManager {
|
||||
dedupStore: this.dedupStore,
|
||||
outbox: this.outbox,
|
||||
onDeliveryFailure: (error) => {
|
||||
if (settings.channel === 'telegram') return
|
||||
if (settings.channel === 'telegram') {
|
||||
deliveryError = `Telegram sending failed: ${redactManagerError(error, [settings.secret])}`
|
||||
onStatus(connectionStatus)
|
||||
return
|
||||
}
|
||||
onStatus({
|
||||
state: 'error',
|
||||
lastError: redactManagerError(error, [
|
||||
@@ -517,7 +525,11 @@ export class ChannelManager {
|
||||
})
|
||||
},
|
||||
onDeliverySuccess: () => {
|
||||
if (settings.channel === 'telegram') return
|
||||
if (settings.channel === 'telegram') {
|
||||
deliveryError = undefined
|
||||
onStatus(connectionStatus)
|
||||
return
|
||||
}
|
||||
onStatus({ state: 'running' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -318,6 +318,18 @@ describe('TelegramChannelDriver', () => {
|
||||
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
|
||||
|
||||
@@ -95,7 +95,11 @@ export class TelegramChannelDriver implements ChannelDriver {
|
||||
}
|
||||
|
||||
testConnection(): Promise<BotIdentity> {
|
||||
return this.track(this.validate(this.lifecycle.signal))
|
||||
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 {
|
||||
@@ -184,23 +188,32 @@ export class TelegramChannelDriver implements ChannelDriver {
|
||||
}
|
||||
}
|
||||
|
||||
private async validate(signal: AbortSignal): Promise<BotIdentity> {
|
||||
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)
|
||||
return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) }
|
||||
}
|
||||
|
||||
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) {
|
||||
this.identity = await this.validate(signal)
|
||||
// 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' })
|
||||
@@ -317,8 +330,10 @@ export class TelegramChannelDriver implements ChannelDriver {
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (failure.code !== 429 || !retryRateLimit || attempt >= 3) throw failure
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
+55
-5
@@ -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,
|
||||
@@ -6456,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())
|
||||
@@ -6650,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)
|
||||
@@ -6678,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')
|
||||
}
|
||||
@@ -6708,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() }
|
||||
})
|
||||
|
||||
|
||||
+3
-4
@@ -7071,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)
|
||||
@@ -7083,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)
|
||||
@@ -7216,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(() =>
|
||||
|
||||
@@ -14110,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,
|
||||
@@ -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);
|
||||
|
||||
@@ -152,7 +152,9 @@ function createProps(
|
||||
|
||||
it('graph navigation reopens the graph tab on repeated requests and preserves normal tab changes', async () => {
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
|
||||
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never
|
||||
const result = { id: 'A', storyLineId: 'story-A', sourceId: null, summary: 'Saved review', changeDigest: '',
|
||||
createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, openItems: [], timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } }
|
||||
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [result], graph } } as never
|
||||
const props = createProps({ graphNavigation: { resultId: 'A' } })
|
||||
const view = renderComponent(<HeartbeatCenter {...props} />)
|
||||
const tab = screen.getByRole('tab', { name: '故事线图谱' })
|
||||
@@ -236,7 +238,7 @@ describe('HeartbeatCenter', () => {
|
||||
expect(within(panel).getByRole('button', { name: t('center.actions.refresh') })).toBeVisible()
|
||||
}
|
||||
if (key === 'supervisor.graph') {
|
||||
const graphNavigation = within(panel).getByRole('tablist', { name: t('supervisor.selection') })
|
||||
const graphNavigation = await within(panel).findByRole('tablist', { name: t('supervisor.selection') })
|
||||
expect(screen.getAllByRole('tablist')).toEqual([navigation, graphNavigation])
|
||||
expect(within(panel).getAllByRole('tablist')).toEqual([graphNavigation])
|
||||
const graphTabs = ['event', 'entity', 'relation'].map((kind) =>
|
||||
@@ -292,11 +294,14 @@ describe('HeartbeatCenter', () => {
|
||||
expect(await screen.findByText('Older review body')).toBeVisible()
|
||||
expect(screen.getByRole('tab', { name: '工作回顾' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(overview).toHaveBeenCalledWith({ resultId: 'old' })
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '在图谱中查看' }))
|
||||
expect(screen.getByRole('tab', { name: '故事线图谱' })).toHaveAttribute('aria-selected', 'true')
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
await waitFor(() => {
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('edits the supervision timeout and concurrency and preserves failed edits (%s)', async (language) => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
import { useSupervisionStories } from './SupervisionStories'
|
||||
|
||||
const scopeA = { kind: 'projects' as const, projectIds: ['11111111-1111-4111-8111-111111111111'] }
|
||||
const scopeB = { kind: 'projects' as const, projectIds: ['22222222-2222-4222-8222-222222222222'] }
|
||||
const rename = { action: 'rename' as const, storyId: '11111111-1111-4111-8111-111111111111', name: 'Renamed story' }
|
||||
const snapshot = (name: string): SupervisionStoryView => ({ stories: [{ id: rename.storyId, name, projectId: null,
|
||||
projectName: null, parentId: null, level: 'feature', description: '', state: 'active', stateEventId: null,
|
||||
userEdited: false, events: [], startedAt: null, endedAt: null }], experiences: [], unassigned: 0, canUndo: false })
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.goodbuddy = {} as never })
|
||||
|
||||
it.each(['scope', 'refresh revision'] as const)('does not let a retained mutation reload invalidate the new %s read', async change => {
|
||||
const initial = snapshot('Initial'), current = snapshot('Current'), stale = snapshot('Stale mutation reload')
|
||||
let finishMutation!: () => void
|
||||
let finishCurrent!: (value: SupervisionStoryView) => void
|
||||
const storyAction = vi.fn(() => new Promise<void>(resolve => { finishMutation = resolve }))
|
||||
const stories = vi.fn().mockResolvedValueOnce(initial)
|
||||
.mockImplementationOnce(() => new Promise<SupervisionStoryView>(resolve => { finishCurrent = resolve }))
|
||||
.mockResolvedValue(stale)
|
||||
window.goodbuddy = { supervision: { stories, storyAction } } as never
|
||||
const revision = {}
|
||||
const view = renderHook(({ scope, revision }) => useSupervisionStories(scope, true, revision),
|
||||
{ initialProps: { scope: scopeA, revision } })
|
||||
await waitFor(() => expect(view.result.current.view).toBe(initial))
|
||||
let mutation!: Promise<boolean>
|
||||
act(() => { mutation = view.result.current.act(rename) })
|
||||
view.rerender({ scope: change === 'scope' ? scopeB : scopeA, revision: change === 'scope' ? revision : {} })
|
||||
await waitFor(() => expect(stories).toHaveBeenCalledTimes(2))
|
||||
// The old action finishes while the replacement read is still pending.
|
||||
await act(async () => { finishMutation(); await mutation })
|
||||
await act(async () => finishCurrent(current))
|
||||
await waitFor(() => expect(view.result.current.view).toBe(current))
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
expect(view.result.current.error).toBeUndefined()
|
||||
expect(view.result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a retained reload before sending a request after same-scope refresh or unmount', async () => {
|
||||
const current = snapshot('Current')
|
||||
const stories = vi.fn().mockResolvedValue(current)
|
||||
window.goodbuddy = { supervision: { stories } } as never
|
||||
const view = renderHook(({ revision }) => useSupervisionStories(scopeA, true, revision), { initialProps: { revision: 1 } })
|
||||
await waitFor(() => expect(view.result.current.view).toBe(current))
|
||||
const obsoleteReload = view.result.current.reload
|
||||
view.rerender({ revision: 2 })
|
||||
await waitFor(() => expect(stories).toHaveBeenCalledTimes(2))
|
||||
await act(async () => obsoleteReload())
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
const unmountedReload = view.result.current.reload
|
||||
view.unmount()
|
||||
await unmountedReload()
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('still reloads an owned mutation and reuses its refreshed snapshot on tab return', async () => {
|
||||
const initial = snapshot('Initial'), updated = snapshot('Renamed story')
|
||||
const stories = vi.fn().mockResolvedValueOnce(initial).mockResolvedValue(updated)
|
||||
const storyAction = vi.fn().mockResolvedValue(undefined)
|
||||
window.goodbuddy = { supervision: { stories, storyAction } } as never
|
||||
const view = renderHook(({ active }) => useSupervisionStories(scopeA, active, 1), { initialProps: { active: true } })
|
||||
await waitFor(() => expect(view.result.current.view).toBe(initial))
|
||||
await act(async () => { expect(await view.result.current.act(rename)).toBe(true) })
|
||||
expect(view.result.current.view).toBe(updated)
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
expect(storyAction).toHaveBeenCalledWith(rename)
|
||||
view.rerender({ active: false })
|
||||
view.rerender({ active: true })
|
||||
expect(view.result.current.view).toBe(updated)
|
||||
expect(stories).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import type { SupervisionExperience, SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
@@ -12,20 +12,32 @@ export function useSupervisionStories(scope: SupervisionRunRequest['scope'] | un
|
||||
const [view, setView] = useState<{ key: string; data: SupervisionStoryView }>({ key: '', data: empty })
|
||||
const [error, setError] = useState<string>()
|
||||
const [pending, setPending] = useState(false)
|
||||
const latestKey = useRef(key)
|
||||
useEffect(() => { latestKey.current = key }, [key])
|
||||
const latest = useRef<{ key: string; revision: unknown } | undefined>(undefined)
|
||||
const loaded = useRef<{ key: string; revision: unknown } | undefined>(undefined)
|
||||
const generation = useRef(0)
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { key, revision }
|
||||
return () => { latest.current = undefined }
|
||||
}, [key, revision])
|
||||
const load = useCallback(async () => {
|
||||
if (!api?.stories || !key) return
|
||||
const owner = latest.current
|
||||
// A completed mutation may still hold the previous scope/refresh callback.
|
||||
if (!api?.stories || !key || !owner || owner.key !== key || owner.revision !== revision) return
|
||||
const request = ++generation.current
|
||||
try {
|
||||
const data = await api.stories({ scope: JSON.parse(key) as SupervisionRunRequest['scope'] })
|
||||
if (latestKey.current === key) { setView({ key, data }); setError(undefined) }
|
||||
} catch (reason) { if (latestKey.current === key) setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
}, [api, key])
|
||||
if (latest.current === owner && request === generation.current) {
|
||||
loaded.current = { key, revision }
|
||||
setView({ key, data }); setError(undefined)
|
||||
}
|
||||
} catch (reason) { if (latest.current === owner && request === generation.current) setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
}, [api, key, revision])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
if (!active || (loaded.current?.key === key && loaded.current.revision === revision)) return
|
||||
const task = window.setTimeout(() => void load(), 0)
|
||||
return () => window.clearTimeout(task)
|
||||
}, [active, load, revision])
|
||||
const invalidate = () => { generation.current++ }
|
||||
return () => { window.clearTimeout(task); invalidate() }
|
||||
}, [active, key, load, revision])
|
||||
const act = async (action: SupervisionStoryAction) => {
|
||||
if (!api?.storyAction || pending) return false
|
||||
setPending(true)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionStoryView } from '../../shared/supervision-story-contracts'
|
||||
import { storyDigest, type StoryDigestEntry } from './supervision-story-digest'
|
||||
import type { StoryDigest, StoryDigestEntry } from './supervision-story-digest'
|
||||
|
||||
type Focus = { kind: 'story' | 'experience'; id: string }
|
||||
const shown = 6
|
||||
@@ -11,15 +10,13 @@ const shown = 6
|
||||
* started, finished or stayed quiet, and which experiences formed or were used. Reads stored
|
||||
* stories only; each item opens the story or experience in the graph.
|
||||
*/
|
||||
export function SupervisionStoryDigest({ view, range, date, onOpen }: {
|
||||
view: SupervisionStoryView
|
||||
range: { from: string; to: string }
|
||||
export function SupervisionStoryDigest({ digest, date, onOpen }: {
|
||||
digest: StoryDigest
|
||||
date: (value: string) => string
|
||||
onOpen: (focus: Focus) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const digest = storyDigest(view, range)
|
||||
const total = digest.advanced.length + digest.started.length + digest.concluded.length + digest.experiences.length
|
||||
const limit = <T,>(key: string, items: T[]) => expanded[key] ? items : items.slice(0, shown)
|
||||
const more = (key: string, count: number) => count > shown && !expanded[key]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SupervisionGraphView } from '../../shared/supervision-contracts'
|
||||
import { useListWindow } from './use-list-window'
|
||||
|
||||
type Kind = 'event' | 'entity' | 'relation'
|
||||
|
||||
export function SupervisorSelectionList({ kind, events, entities, relations, entityMap, selectedId, onSelect, date, shortDate, entityTone, id, labelledBy }: {
|
||||
kind: Kind
|
||||
events: SupervisionGraphView['events']
|
||||
entities: SupervisionGraphView['entities']
|
||||
relations: SupervisionGraphView['relations']
|
||||
entityMap: Map<string, SupervisionGraphView['entities'][number]>
|
||||
selectedId?: string
|
||||
onSelect: (selection: { kind: Kind; id: string }) => void
|
||||
date: (value: string) => string
|
||||
shortDate: (value: string) => string
|
||||
entityTone: (id: string) => number
|
||||
id: string
|
||||
labelledBy: string
|
||||
}) {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [keyboardId, setKeyboardId] = useState<string>()
|
||||
const records = kind === 'event' ? events : kind === 'entity' ? entities : relations
|
||||
const ids = useMemo(() => records.map(record => record.id), [records])
|
||||
// Native Tab entry from either side must reach the logical endpoints, even when scrolled away.
|
||||
const list = useListWindow({ ids, scope: kind, scrollRef: ref, keepIds: [ids[0], ids.at(-1), selectedId, keyboardId], enabled: true, estimatedRowHeight: kind === 'event' ? 64 : 44 })
|
||||
const { onScroll } = list
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = ref.current
|
||||
if (!panel || !selectedId) return
|
||||
const row = Array.from(panel.querySelectorAll<HTMLElement>('[data-list-window-row]')).find(item => item.dataset.listWindowRow === selectedId)
|
||||
if (!row) return
|
||||
const bounds = panel.getBoundingClientRect(), rect = row.getBoundingClientRect()
|
||||
if (rect.top < bounds.top) panel.scrollTop += rect.top - bounds.top
|
||||
else if (rect.bottom > bounds.bottom) panel.scrollTop += rect.bottom - bounds.bottom
|
||||
onScroll()
|
||||
}, [selectedId, onScroll])
|
||||
|
||||
return <div ref={ref} className="supervisor-workspace__list-panel supervisor-workspace__selection-list"
|
||||
role="tabpanel" id={id} aria-labelledby={labelledBy} tabIndex={0}
|
||||
onScroll={list.onScroll} onFocus={list.onFocus} onBlur={list.onBlur}
|
||||
onKeyDown={event => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Tab'].includes(event.key) || !ids.length) return
|
||||
const row = (event.target as HTMLElement).closest<HTMLElement>('[data-list-window-row]')
|
||||
const index = row ? ids.indexOf(row.dataset.listWindowRow!) : -1
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? ids.length - 1
|
||||
: index + (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1)
|
||||
if (event.key === 'Tab' && (index < 0 || next < 0 || next >= ids.length)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const nextId = ids[Math.max(0, Math.min(ids.length - 1, next))]!
|
||||
// Materialize the logical next row before native focus, including across spacers.
|
||||
flushSync(() => setKeyboardId(nextId))
|
||||
const target = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? []).find(item => item.dataset.listWindowRow === nextId)
|
||||
target?.querySelector('button')?.focus()
|
||||
target?.scrollIntoView?.({ block: 'nearest' })
|
||||
list.onScroll()
|
||||
}}>
|
||||
{!ids.length && <p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>}
|
||||
{list.segments.map(segment => {
|
||||
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height, flex: '0 0 auto' }} />
|
||||
const index = segment.index, rowId = ids[index]!
|
||||
const event = kind === 'event' ? events[index] : undefined
|
||||
const entity = kind === 'entity' ? entities[index] : undefined
|
||||
const relation = kind === 'relation' ? relations[index] : undefined
|
||||
return <div key={rowId} data-list-window-row={rowId} ref={list.rowRef(rowId)} className="supervisor-workspace__list-row">
|
||||
<button type="button" aria-pressed={selectedId === rowId}
|
||||
aria-label={event ? `${index + 1}. ${event.title} · ${date(event.occurred_at)}` : undefined}
|
||||
data-tone={entity ? entityTone(rowId) : undefined} onClick={() => onSelect({ kind, id: rowId })}>
|
||||
{event ? <><span className="supervisor-workspace__event-index">{index + 1}.</span>
|
||||
<span className="supervisor-workspace__list-copy">{event.title}<small title={date(event.occurred_at)}>{shortDate(event.occurred_at)}</small></span></>
|
||||
: entity ? <><i className="supervisor-workspace__entity-dot" aria-hidden="true" /><span className="supervisor-workspace__list-copy">{entity.canonical_label}</span></>
|
||||
: relation ? <>{entityMap.get(relation.from_entity_id)?.canonical_label} → {entityMap.get(relation.to_entity_id)?.canonical_label} · {t(`supervisor.relationTypes.${relation.relation_type}`, { defaultValue: relation.relation_type })}</> : null}
|
||||
</button>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SupervisorWorkspace } from './SupervisorWorkspace'
|
||||
import { changeUiLocale, i18nResources } from './i18n'
|
||||
import * as digestModule from './supervision-story-digest'
|
||||
|
||||
const result = { id: 'result-1', storyLineId: 'story', sourceId: null, summary: 'Recap', changeDigest: '', createdAt: '2026-09-22T00:00:00.000Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-20T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' }, openItems: [] }
|
||||
const render = (ui: React.ReactNode) => {
|
||||
@@ -15,6 +16,143 @@ const selectHistory = (id: string) => {
|
||||
}
|
||||
|
||||
describe('SupervisorWorkspace', () => {
|
||||
it('loads recap independently of graph and reuses the graph across recap switches until refreshed', async () => {
|
||||
const data = { storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }
|
||||
const graph = vi.fn().mockResolvedValue(data)
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
const view = render(<SupervisorWorkspace />)
|
||||
await screen.findByText('Recap')
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
expect(screen.getByRole('group', { name: '故事线图谱' })).toBeVisible()
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).toHaveBeenCalledOnce()
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('computes one digest per story snapshot and keeps it on warm switches and unrelated edits', async () => {
|
||||
const snapshot = { stories: [{ id: 's', name: 'Story', projectId: null, projectName: null, parentId: null, level: 'feature',
|
||||
description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: result.createdAt, endedAt: null,
|
||||
events: [{ id: 'e', title: 'Evidence', projectId: null, startedAt: result.createdAt, endedAt: result.createdAt, primary: true, userSet: false }] }],
|
||||
experiences: [], unassigned: 0, canUndo: false }
|
||||
const stories = vi.fn().mockResolvedValue(snapshot)
|
||||
const digest = vi.spyOn(digestModule, 'storyDigest')
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], stories,
|
||||
graph: async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
|
||||
const view = render(<SupervisorWorkspace />)
|
||||
await screen.findByRole('article', { name: '按故事查看本次回顾' })
|
||||
expect(digest.mock.calls.filter(([data]) => data === snapshot)).toHaveLength(1)
|
||||
digest.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
expect(stories).toHaveBeenCalledOnce()
|
||||
expect(digest).not.toHaveBeenCalled()
|
||||
digest.mockRestore()
|
||||
})
|
||||
|
||||
it('bounds graph list rows while keeping the last record and keyboard traversal reachable', async () => {
|
||||
const events = Array.from({ length: 600 }, (_, i) => ({ id: `e${i}`, title: `Event ${i}`, description: `Detail ${i}`, occurred_at: result.createdAt }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result],
|
||||
graph: async () => ({ storyLine: null, events, entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
|
||||
render(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
const panel = screen.getByRole('tabpanel')
|
||||
expect(within(panel).getAllByRole('button').length).toBeLessThanOrEqual(122)
|
||||
fireEvent.keyDown(panel, { key: 'End' })
|
||||
expect(within(panel).getByRole('button', { name: /600\. Event 599/ })).toHaveFocus()
|
||||
fireEvent.click(document.activeElement!)
|
||||
expect(screen.getByText('Detail 599')).toBeVisible()
|
||||
fireEvent.keyDown(panel, { key: 'Home' })
|
||||
expect(within(panel).getByRole('button', { name: /1\. Event 0 / })).toHaveFocus()
|
||||
for (let i = 0; i < 125; i++) fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
|
||||
expect(within(panel).getByRole('button', { name: /126\. Event 125/ })).toHaveFocus()
|
||||
expect(within(panel).getAllByRole('button').length).toBeLessThanOrEqual(123)
|
||||
})
|
||||
|
||||
it('leaves a pending or failed graph without blocking recap and retries only on graph demand', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
const graphData = (title: string) => ({ storyLine: null, events: [{ id: 'e', title, description: '', occurred_at: result.createdAt }],
|
||||
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })
|
||||
const graph = vi.fn().mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
.mockRejectedValueOnce(new Error('graph failed')).mockResolvedValue(graphData('Current event'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
const view = render(<SupervisorWorkspace />)
|
||||
await screen.findByText('Recap')
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledOnce())
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
expect(screen.getByLabelText('历史结果')).toBeEnabled()
|
||||
await act(async () => finish(graphData('Obsolete event')))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByText('Obsolete event')).not.toBeInTheDocument()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Recap')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
expect(graph).toHaveBeenCalledTimes(3)
|
||||
expect(screen.queryByText('Obsolete event')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing historical recap navigation retryable without falling back to the latest result', async () => {
|
||||
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId ? [] : [result])
|
||||
const graph = vi.fn()
|
||||
window.goodbuddy = { supervision: { overview, graph } } as never
|
||||
render(<SupervisorWorkspace graphNavigation={{ resultId: 'deleted' }} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
|
||||
expect(screen.queryByText('Recap')).not.toBeInTheDocument()
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(overview.mock.calls.filter(([input]) => input?.resultId === 'deleted')).toHaveLength(2))
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates a cached graph after entity edits and keeps source bodies demand-loaded', async () => {
|
||||
let confirmed = false
|
||||
const graph = vi.fn(async () => ({ storyLine: null,
|
||||
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
|
||||
entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: confirmed ? 'confirmed' : 'automatic' }],
|
||||
relations: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
|
||||
eventEntities: [{ event_id: 'event', entity_id: 'entity' }], eventSources: [{ event_id: 'event', source_id: 'source' }] }))
|
||||
const entityAction = vi.fn(async () => { confirmed = true })
|
||||
const source = vi.fn().mockResolvedValue({ title: 'Source', content: 'Full saved body', occurredAt: result.createdAt })
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph, source, entityAction } } as never
|
||||
const view = render(<SupervisorWorkspace tab="graph" />)
|
||||
await screen.findByRole('group', { name: '故事线图谱' })
|
||||
expect(source).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('tab', { name: /实体/ }))
|
||||
fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: 'Entity' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm }))
|
||||
await waitFor(() => {
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('历史结果')).toBeEnabled()
|
||||
})
|
||||
expect(entityAction).toHaveBeenCalledWith({ entityId: 'entity', resultId: result.id, action: 'confirm' })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
|
||||
expect(await screen.findByText('Full saved body')).toBeVisible()
|
||||
view.rerender(<SupervisorWorkspace />)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" />)
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByText('Full saved body')).toBeVisible()
|
||||
expect(source).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => {
|
||||
let finishReview!: () => void
|
||||
const run = vi.fn(() => new Promise<void>(resolve => { finishReview = resolve }))
|
||||
@@ -339,7 +477,7 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(screen.getByLabelText('时间范围')).toHaveValue('30')
|
||||
expect(screen.getByLabelText('关注范围')).toHaveValue(project.id)
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
@@ -421,7 +559,7 @@ describe('SupervisorWorkspace', () => {
|
||||
let resolveSource!: (value: unknown) => void
|
||||
const source = vi.fn(() => new Promise(resolve => { resolveSource = resolve }))
|
||||
const graph = vi.fn(async () => ({ storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }], entities: [], relations: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }], eventEntities: [], eventSources: [{ event_id: 'event', source_id: 'source' }] }))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph, source } } as never
|
||||
window.goodbuddy = { supervision: { overview: async () => [{ ...result, id: 'A' }, { ...result, id: 'B' }], graph, source } } as never
|
||||
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'A' }} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
|
||||
@@ -450,6 +588,9 @@ describe('SupervisorWorkspace', () => {
|
||||
view.rerender(<SupervisorWorkspace graphNavigation={next} />)
|
||||
expect(screen.getByLabelText('历史结果')).toHaveValue('B')
|
||||
selectHistory('A')
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
expect(graph).toHaveBeenCalledTimes(2)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={next} />)
|
||||
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story' }))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
|
||||
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story' }))
|
||||
@@ -458,14 +599,16 @@ describe('SupervisorWorkspace', () => {
|
||||
|
||||
it('graph navigation requests results outside overview and keeps missing results retryable without latest fallback', async () => {
|
||||
const graph = vi.fn().mockRejectedValue(new Error('Missing requested result'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
|
||||
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId ? [] : [result])
|
||||
window.goodbuddy = { supervision: { overview, graph } } as never
|
||||
render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'older' }} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
|
||||
expect(screen.queryByText('Missing requested result')).not.toBeInTheDocument()
|
||||
expect(graph).toHaveBeenCalledWith({ resultId: 'older', storyLineId: undefined })
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
expect(overview).toHaveBeenCalledWith({ resultId: 'older' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'older', storyLineId: undefined })
|
||||
await waitFor(() => expect(overview.mock.calls.filter(([input]) => input?.resultId === 'older')).toHaveLength(2))
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Recap')).not.toBeInTheDocument()
|
||||
})
|
||||
it('opens the selected result graph and ignores a late response from another project', async () => {
|
||||
@@ -473,15 +616,18 @@ describe('SupervisorWorkspace', () => {
|
||||
const b = { ...result, id: 'B', storyLineId: 'story-B', summary: 'B recap' }
|
||||
const graphFor = (title: string) => ({ storyLine: null, events: [{ id: title, title, description: title, occurred_at: result.createdAt }], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })
|
||||
let resolveA!: (value: unknown) => void
|
||||
const graph = vi.fn().mockResolvedValueOnce(graphFor('B event')).mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })).mockResolvedValueOnce(graphFor('B selected'))
|
||||
const graph = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })).mockResolvedValueOnce(graphFor('B selected'))
|
||||
window.goodbuddy = { supervision: { overview: async () => [b, a], graph } } as never
|
||||
const onTabChange = vi.fn()
|
||||
const view = render(<SupervisorWorkspace onTabChange={onTabChange} />)
|
||||
await screen.findByText('B recap')
|
||||
expect(graph).not.toHaveBeenCalled()
|
||||
selectHistory('A')
|
||||
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
|
||||
view.rerender(<SupervisorWorkspace tab="graph" onTabChange={onTabChange} />)
|
||||
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story-A' }))
|
||||
const navigation = { resultId: 'B' }
|
||||
view.rerender(<SupervisorWorkspace graphNavigation={navigation} onTabChange={onTabChange} />)
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
|
||||
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story-B' }))
|
||||
await act(async () => resolveA(graphFor('Wrong A event')))
|
||||
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
|
||||
@@ -495,7 +641,7 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(tokens, token).toContain(`${token}:`)
|
||||
}
|
||||
})
|
||||
afterEach(async () => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
|
||||
afterEach(async () => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
|
||||
it('shows unavailable immediately and does not call supervision APIs when the bridge is absent', () => {
|
||||
window.goodbuddy = {} as never
|
||||
|
||||
@@ -849,7 +995,8 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(circles).toHaveLength(8)
|
||||
expect(new Set(circles.map(circle => `${circle.getAttribute('cx')},${circle.getAttribute('cy')}`)).size).toBe(8)
|
||||
const list = screen.getByRole('complementary', { name: '图谱选择' })
|
||||
expect(within(list).getAllByRole('button')).toHaveLength(80)
|
||||
expect(within(list).getAllByRole('button').length).toBeLessThan(80)
|
||||
fireEvent.keyDown(within(list).getByRole('tabpanel'), { key: 'End' })
|
||||
fireEvent.click(within(list).getByRole('button', { name: /80\. 事件 79/ }))
|
||||
expect(within(graph).getByRole('button', { name: '事件 79' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(within(screen.getByRole('complementary', { name: '详情与来源' })).getByText('详情 79')).toBeInTheDocument()
|
||||
|
||||
@@ -10,6 +10,7 @@ import { storyDigest } from './supervision-story-digest'
|
||||
import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
|
||||
import { useFillHeight } from './use-fill-height'
|
||||
import { SupervisorSelectionList } from './SupervisorSelectionList'
|
||||
import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
|
||||
import type { AssistantProject } from '../../shared/assistant-contracts'
|
||||
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
|
||||
@@ -81,8 +82,10 @@ export function SupervisorWorkspace({
|
||||
const api = window.goodbuddy?.supervision
|
||||
const [results, setResults] = useState<SupervisionResultView[]>([])
|
||||
const [graph, setGraph] = useState(emptyGraph)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string>()
|
||||
const [overviewLoading, setOverviewLoading] = useState(true)
|
||||
const [overviewError, setOverviewError] = useState<string>()
|
||||
const [graphResultId, setGraphResultId] = useState<string>()
|
||||
const [graphError, setGraphError] = useState<string>()
|
||||
const [pending, setPending] = useState<string>()
|
||||
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
|
||||
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
|
||||
@@ -122,26 +125,29 @@ export function SupervisorWorkspace({
|
||||
setAppliedNavigation(graphNavigation)
|
||||
setResultId(graphNavigation?.resultId)
|
||||
setGraph(emptyGraph)
|
||||
setLoading(true)
|
||||
setGraphResultId(undefined)
|
||||
setGraphError(undefined)
|
||||
setOverviewLoading(true)
|
||||
setSource(undefined)
|
||||
setSelection(graphNavigation?.focus)
|
||||
setDetailOpen(Boolean(graphNavigation?.focus))
|
||||
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
|
||||
setPending(undefined)
|
||||
setLoadError(undefined)
|
||||
setOverviewError(undefined)
|
||||
}
|
||||
const selectedResult = useRef<string | undefined>(undefined)
|
||||
const requestedNavigation = useRef<SupervisionGraphNavigation | undefined>(undefined)
|
||||
const loadGeneration = useRef(0)
|
||||
const [confirmRemoval, setConfirmRemoval] = useState(false)
|
||||
const [revision, setRevision] = useState<string>()
|
||||
const date = (value: string) =>
|
||||
new Date(value).toLocaleString(i18n.resolvedLanguage)
|
||||
const shortDate = (value: string) =>
|
||||
new Date(value).toLocaleDateString(i18n.resolvedLanguage, {
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.resolvedLanguage, {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'
|
||||
}), [i18n.resolvedLanguage])
|
||||
const shortDateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.resolvedLanguage, {
|
||||
month: '2-digit', day: '2-digit'
|
||||
}), [i18n.resolvedLanguage])
|
||||
const date = (value: string) => dateFormatter.format(new Date(value))
|
||||
const shortDate = (value: string) => shortDateFormatter.format(new Date(value))
|
||||
const scopeText = (scope: SupervisionRunRequest['scope']) =>
|
||||
scope.kind === 'global'
|
||||
? t('center.scope.global')
|
||||
@@ -160,13 +166,15 @@ export function SupervisorWorkspace({
|
||||
const generation = ++loadGeneration.current
|
||||
setPending(undefined)
|
||||
selectedResult.current = requestedId
|
||||
setLoading(true)
|
||||
setOverviewLoading(true)
|
||||
setGraph(emptyGraph)
|
||||
setGraphResultId(undefined)
|
||||
setGraphError(undefined)
|
||||
setResultId(requestedId)
|
||||
setSource(undefined)
|
||||
setConfirmRemoval(false)
|
||||
setRevision(undefined)
|
||||
setLoadError(undefined)
|
||||
setOverviewError(undefined)
|
||||
try {
|
||||
const overview = (await api.overview()).map((item) => supervisionResultViewSchema.parse(item))
|
||||
if (generation !== loadGeneration.current) return
|
||||
@@ -177,35 +185,15 @@ export function SupervisorWorkspace({
|
||||
}
|
||||
const selected = requestedId ? overview.find((item) => item.id === requestedId) : overview[0]
|
||||
setResults(overview)
|
||||
if (requestedId && !selected) throw new Error('Requested supervision result is unavailable')
|
||||
const nextId = requestedId ?? selected?.id
|
||||
setResultId(nextId)
|
||||
selectedResult.current = nextId
|
||||
const nextGraph = nextId ? await api.graph({ resultId: nextId, storyLineId: selected?.storyLineId }) : emptyGraph
|
||||
if (generation !== loadGeneration.current) return
|
||||
const parsedGraph = supervisionGraphViewSchema.parse(nextGraph)
|
||||
if (parsedGraph.storyLine)
|
||||
heartbeatScopeSchema.parse(JSON.parse(parsedGraph.storyLine.scope_json))
|
||||
setGraph(parsedGraph)
|
||||
setSelection((current) => {
|
||||
const records =
|
||||
current?.kind === 'event'
|
||||
? parsedGraph.events
|
||||
: current?.kind === 'entity'
|
||||
? parsedGraph.entities
|
||||
: parsedGraph.relations
|
||||
if (current?.kind === 'story' || current?.kind === 'experience') return current
|
||||
if (current && records.some((item) => item.id === current.id))
|
||||
return current
|
||||
const latestEvent = [...parsedGraph.events].sort(
|
||||
(a, b) => Date.parse(b.occurred_at) - Date.parse(a.occurred_at)
|
||||
)[0]
|
||||
return latestEvent ? { kind: 'event', id: latestEvent.id } : undefined
|
||||
})
|
||||
} catch {
|
||||
if (generation !== loadGeneration.current) return
|
||||
setLoadError(t('supervisor.loadFailed'))
|
||||
setOverviewError(t('supervisor.loadFailed'))
|
||||
} finally {
|
||||
if (generation === loadGeneration.current) setLoading(false)
|
||||
if (generation === loadGeneration.current) setOverviewLoading(false)
|
||||
}
|
||||
}, [api, t])
|
||||
useEffect(() => {
|
||||
@@ -219,6 +207,36 @@ export function SupervisorWorkspace({
|
||||
return () => { window.clearTimeout(task); invalidate() }
|
||||
}, [refresh, graphNavigation])
|
||||
|
||||
const latest = resultId ? results.find((item) => item.id === resultId) : results[0]
|
||||
const storyLineId = latest?.storyLineId
|
||||
// The selected result is the only graph cache. Refresh/navigation invalidate it;
|
||||
// switching tabs keeps it, and an obsolete in-flight read cannot refill it.
|
||||
useEffect(() => {
|
||||
if (!api || tab !== 'graph' || overviewLoading || overviewError || !resultId || graphResultId === resultId || graphError) return
|
||||
let active = true
|
||||
const generation = loadGeneration.current
|
||||
void api.graph({ resultId, storyLineId }).then(value => {
|
||||
if (!active || generation !== loadGeneration.current) return
|
||||
const parsed = supervisionGraphViewSchema.parse(value)
|
||||
if (parsed.storyLine) heartbeatScopeSchema.parse(JSON.parse(parsed.storyLine.scope_json))
|
||||
setGraph(parsed)
|
||||
setGraphResultId(resultId)
|
||||
setSelection(current => {
|
||||
if (current?.kind === 'story' || current?.kind === 'experience') return current
|
||||
const records = current?.kind === 'event' ? parsed.events : current?.kind === 'entity' ? parsed.entities : parsed.relations
|
||||
if (current && records.some(item => item.id === current.id)) return current
|
||||
const newest = parsed.events.reduce<SupervisionGraphView['events'][number] | undefined>((found, event) =>
|
||||
!found || Date.parse(event.occurred_at) > Date.parse(found.occurred_at) ? event : found, undefined)
|
||||
return newest ? { kind: 'event', id: newest.id } : undefined
|
||||
})
|
||||
}).catch(() => {
|
||||
if (active && generation === loadGeneration.current) setGraphError(t('supervisor.loadFailed'))
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [api, tab, overviewLoading, overviewError, resultId, storyLineId, graphResultId, graphError, t])
|
||||
const loadError = overviewError ?? (tab === 'graph' ? graphError : undefined)
|
||||
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
|
||||
|
||||
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
|
||||
if (period === 'range') {
|
||||
const from = new Date(`${startDate}T00:00:00`)
|
||||
@@ -510,11 +528,10 @@ export function SupervisorWorkspace({
|
||||
if (generation === loadGeneration.current) setPending(undefined)
|
||||
}
|
||||
}
|
||||
const latest = resultId ? results.find((item) => item.id === resultId) : results[0]
|
||||
const graphScope = graph.storyLine
|
||||
const graphScope = useMemo(() => graph.storyLine
|
||||
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
|
||||
: undefined
|
||||
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
|
||||
: undefined, [graph.storyLine])
|
||||
const storyState = useSupervisionStories(latest?.scope ?? graphScope, tab === 'graph' || tab === 'overview', results)
|
||||
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
|
||||
const graphLayoutRef = useRef<HTMLDivElement>(null)
|
||||
// Flat labels need a taller minimum; the spiral can use short windows without that constraint.
|
||||
@@ -523,8 +540,8 @@ export function SupervisorWorkspace({
|
||||
// Without a graph, the empty state takes the visible height so its message sits in the middle of the page.
|
||||
useFillHeight(graphEmptyRef, tab === 'graph' && !loading && !loadError && graph.events.length === 0, 24, 320)
|
||||
// The work review reads the selected result's own scope, so it matches the period shown.
|
||||
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
|
||||
const digest = latest && recapStories.available ? storyDigest(recapStories.view, latest.timeRange) : undefined
|
||||
const digest = useMemo(() => latest && storyState.available ? storyDigest(storyState.view, latest.timeRange) : undefined,
|
||||
[latest, storyState.available, storyState.view])
|
||||
const hasDigest = digest && [digest.advanced, digest.started, digest.concluded, digest.experiences, digest.quiet].some(items => items.length > 0)
|
||||
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
|
||||
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
|
||||
@@ -713,7 +730,7 @@ export function SupervisorWorkspace({
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{hasDigest && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
|
||||
{hasDigest && <SupervisionStoryDigest digest={digest} date={date}
|
||||
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
|
||||
</aside>}
|
||||
</article>
|
||||
@@ -774,7 +791,11 @@ export function SupervisorWorkspace({
|
||||
{ id: 'experience' as const, label: `${t('supervisor.listTabs.experience')} ${storyState.view.experiences.length}` }] : [])
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
{listTab === 'event' || listTab === 'entity' || listTab === 'relation' ? <SupervisorSelectionList
|
||||
key={`${resultId}:${listTab}`} kind={listTab} events={layout.events} entities={layout.entities} relations={graph.relations}
|
||||
entityMap={layout.entityMap} selectedId={selection?.kind === listTab ? selection.id : undefined}
|
||||
onSelect={select} date={date} shortDate={shortDate} entityTone={entityTone}
|
||||
id={`${graphId}-list-panel-${listTab}`} labelledBy={`${graphId}-list-tab-${listTab}`} /> : <div
|
||||
key={listTab}
|
||||
className="supervisor-workspace__list-panel"
|
||||
role="tabpanel"
|
||||
@@ -782,10 +803,6 @@ export function SupervisorWorkspace({
|
||||
aria-labelledby={`${graphId}-list-tab-${listTab}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{((listTab === 'entity' && !layout.entities.length) ||
|
||||
(listTab === 'relation' && !graph.relations.length)) && (
|
||||
<p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>
|
||||
)}
|
||||
{listTab === 'story' && <>
|
||||
{storyState.error && <p role="alert">{storyState.error}</p>}
|
||||
{!storyState.view.stories.length && <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>}
|
||||
@@ -800,67 +817,7 @@ export function SupervisorWorkspace({
|
||||
<SupervisionExperienceList experiences={storyState.view.experiences} selectedId={selectedExperience?.id}
|
||||
onSelect={(id) => select({ kind: 'experience', id })} />
|
||||
</>}
|
||||
{listTab === 'event' && layout.events.map((event, index) => (
|
||||
<button
|
||||
key={event.id}
|
||||
aria-label={`${index + 1}. ${event.title} · ${date(event.occurred_at)}`}
|
||||
aria-pressed={selection?.id === event.id}
|
||||
onClick={() => select({ kind: 'event', id: event.id })}
|
||||
>
|
||||
<span className="supervisor-workspace__event-index">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="supervisor-workspace__list-copy">
|
||||
{event.title}
|
||||
<small title={date(event.occurred_at)}>
|
||||
{shortDate(event.occurred_at)}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{listTab === 'entity' && layout.entities.map((entity) => (
|
||||
<button
|
||||
key={entity.id}
|
||||
data-tone={entityTone(entity.id)}
|
||||
aria-pressed={selection?.id === entity.id}
|
||||
onClick={() =>
|
||||
select({ kind: 'entity', id: entity.id })
|
||||
}
|
||||
>
|
||||
<i
|
||||
className="supervisor-workspace__entity-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="supervisor-workspace__list-copy">
|
||||
{entity.canonical_label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{listTab === 'relation' && graph.relations.map((relation) => (
|
||||
<button
|
||||
key={relation.id}
|
||||
aria-pressed={selection?.id === relation.id}
|
||||
onClick={() =>
|
||||
select({ kind: 'relation', id: relation.id })
|
||||
}
|
||||
>
|
||||
{
|
||||
layout.entityMap.get(relation.from_entity_id)
|
||||
?.canonical_label
|
||||
}{' '}
|
||||
→{' '}
|
||||
{
|
||||
layout.entityMap.get(relation.to_entity_id)
|
||||
?.canonical_label
|
||||
}{' '}
|
||||
·{' '}
|
||||
{t(
|
||||
`supervisor.relationTypes.${relation.relation_type}`,
|
||||
{ defaultValue: relation.relation_type }
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
<p className="supervisor-workspace__muted">
|
||||
{t('supervisor.legend')}
|
||||
</p>
|
||||
|
||||
@@ -39,3 +39,17 @@ it('lists experiences formed in the period, or applied in it when formed earlier
|
||||
expect(storyDigest(view, range).experiences.map(entry => [entry.experience.id, entry.role, entry.events.map(item => item.id)]))
|
||||
.toEqual([['new', 'formed', ['a']], ['reused', 'applied', ['c']]])
|
||||
})
|
||||
|
||||
it('preserves inclusive boundaries, tie ordering, orphan threads and direct-child aggregation', () => {
|
||||
const view: SupervisionStoryView = { unassigned: 0, canUndo: false, experiences: [], stories: [
|
||||
story('feature', [event('b', range.from), event('excluded', range.from, false)]),
|
||||
story('child', [event('a', range.from), event('z', range.to)], { level: 'thread', parentId: 'feature' }),
|
||||
story('grandchild', [event('deep', range.from)], { level: 'thread', parentId: 'child' }),
|
||||
story('orphan', [event('orphan-event', range.to)], { level: 'thread', parentId: 'missing' }),
|
||||
story('cross', [event('linked', range.from, false)], { level: 'cross' })
|
||||
] }
|
||||
const digest = storyDigest(view, range)
|
||||
expect(digest.started.map(entry => [entry.story.id, entry.events.map(item => item.id), entry.latest.id]))
|
||||
.toEqual([['feature', ['a', 'b', 'z'], 'z'], ['orphan', ['orphan-event'], 'orphan-event'], ['cross', ['linked'], 'linked']])
|
||||
expect(view.stories[0]!.events.map(item => item.id)).toEqual(['b', 'excluded'])
|
||||
})
|
||||
|
||||
@@ -28,10 +28,17 @@ export function storyDigest(view: SupervisionStoryView, range: { from: string; t
|
||||
const inRange = (value: string) => { const at = Date.parse(value); return at >= from && at <= to }
|
||||
const digest: StoryDigest = { advanced: [], started: [], concluded: [], quiet: [], experiences: [], unassigned: view.unassigned }
|
||||
// Features show their threads' work too; threads are listed on their own only when the feature is absent.
|
||||
const children = (story: SupervisionStory) => view.stories.filter(child => child.parentId === story.id)
|
||||
const ids = new Set(view.stories.map(story => story.id))
|
||||
const children = new Map<string, SupervisionStory[]>()
|
||||
for (const story of view.stories) {
|
||||
if (story.level === 'thread' && view.stories.some(parent => parent.id === story.parentId)) continue
|
||||
const events = [...own(story), ...children(story).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id))
|
||||
if (story.parentId === null) continue
|
||||
const siblings = children.get(story.parentId)
|
||||
if (siblings) siblings.push(story)
|
||||
else children.set(story.parentId, [story])
|
||||
}
|
||||
for (const story of view.stories) {
|
||||
if (story.level === 'thread' && story.parentId !== null && ids.has(story.parentId)) continue
|
||||
const events = [...own(story), ...(children.get(story.id) ?? []).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id))
|
||||
if (!events.length) continue
|
||||
const during = events.filter(event => inRange(event.startedAt))
|
||||
const before = events.filter(event => Date.parse(event.startedAt) < from)
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
padding-inline: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button,
|
||||
.supervisor-workspace__list-row > button,
|
||||
.supervisor-workspace__story-group button {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
@@ -810,10 +811,12 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
.supervisor-workspace__list-panel > button:hover,
|
||||
.supervisor-workspace__list-row > button:hover,
|
||||
.supervisor-workspace__story-group button:hover {
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.supervisor-workspace__list-panel > button[aria-pressed='true'],
|
||||
.supervisor-workspace__list-row > button[aria-pressed='true'],
|
||||
.supervisor-workspace__story-group button[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
@@ -976,6 +979,17 @@
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.supervisor-workspace__graph-list > .supervisor-workspace__selection-list {
|
||||
gap: 0;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
.supervisor-workspace__list-row {
|
||||
flex: 0 0 auto;
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
.supervisor-workspace__list-row > button {
|
||||
width: 100%;
|
||||
}
|
||||
.supervisor-activity > .supervisor-workspace__action-bar {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRef } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { installFakeListLayout } from './list-window-test-layout'
|
||||
import { useListWindow } from './use-list-window'
|
||||
|
||||
const ids = Array.from({ length: 600 }, (_, index) => `row-${index}`)
|
||||
function List() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const list = useListWindow({ ids, scope: 'test', scrollRef: ref, enabled: true, keepIds: ['row-599'], estimatedRowHeight: 44 })
|
||||
return <div ref={ref} data-scroll-list onScroll={list.onScroll} onFocus={list.onFocus} onBlur={list.onBlur}>
|
||||
{list.segments.map(segment => segment.kind === 'spacer'
|
||||
? <div key={segment.key} data-list-window-spacer style={{ height: segment.height }} />
|
||||
: <div key={ids[segment.index]} data-list-window-row={ids[segment.index]} ref={list.rowRef(ids[segment.index]!)}><button>{ids[segment.index]}</button></div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('keeps visibly focused rows in view when rewindowing replaces measured DOM with estimated spacers', () => {
|
||||
const layout = installFakeListLayout({ isContainer: element => element.hasAttribute('data-scroll-list'),
|
||||
rowAttribute: 'data-list-window-row', rowHeight: () => 38, viewport: 528 })
|
||||
try {
|
||||
const view = render(<List />)
|
||||
const panel = view.container.querySelector<HTMLElement>('[data-scroll-list]')!
|
||||
const row = panel.querySelector<HTMLElement>('[data-list-window-row="row-599"]')!
|
||||
const button = row.querySelector('button')!
|
||||
act(() => { button.focus(); panel.scrollTop = panel.scrollHeight; fireEvent.scroll(panel) })
|
||||
expect(button).toHaveFocus()
|
||||
expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(528)
|
||||
layout.measure()
|
||||
expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(528)
|
||||
// Explicit scrolling away keeps focus mounted, but must not pull the viewport back.
|
||||
layout.scroll(panel, 0)
|
||||
expect(button).toHaveFocus()
|
||||
expect(panel.scrollTop).toBe(0)
|
||||
expect(row.getBoundingClientRect().top).toBeGreaterThan(528)
|
||||
} finally { layout.restore() }
|
||||
})
|
||||
@@ -184,6 +184,7 @@ export function useListWindow({
|
||||
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
|
||||
const observerRef = useRef<ResizeObserver | undefined>(undefined)
|
||||
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
|
||||
const visibleFocusRef = useRef<HTMLElement | undefined>(undefined)
|
||||
// List-relative scroll position seen at the last scroll event or commit, and
|
||||
// the ids and offsets of the last committed windowed layout. Together they
|
||||
// locate the reader's row when ids change, without reading layout per scroll.
|
||||
@@ -262,9 +263,23 @@ export function useListWindow({
|
||||
return { top, viewport: container ? container.clientHeight : window.innerHeight }
|
||||
}, [])
|
||||
|
||||
const captureVisibleFocus = useCallback((current: Geometry): void => {
|
||||
visibleFocusRef.current = undefined
|
||||
if (current.list.contains(document.activeElement)) {
|
||||
const focused = document.activeElement?.closest<HTMLElement>(rowSelector)
|
||||
if (focused) {
|
||||
const rect = focused.getBoundingClientRect()
|
||||
const top = viewportTop(current.container)
|
||||
const height = current.container?.clientHeight ?? window.innerHeight
|
||||
if (rect.top >= top - 1 && rect.bottom <= top + height + 1) visibleFocusRef.current = focused
|
||||
}
|
||||
}
|
||||
}, [rowSelector])
|
||||
|
||||
/** First mounted row reaching into the viewport, relative to the viewport top. */
|
||||
const captureScrollAnchor = useCallback((): void => {
|
||||
const current = geometry()
|
||||
if (current) captureVisibleFocus(current)
|
||||
if (!current || position(current).top <= 0) {
|
||||
scrollAnchorRef.current = undefined
|
||||
return
|
||||
@@ -278,7 +293,7 @@ export function useListWindow({
|
||||
}
|
||||
}
|
||||
scrollAnchorRef.current = undefined
|
||||
}, [geometry, position, rowAttribute, rowSelector])
|
||||
}, [captureVisibleFocus, geometry, position, rowAttribute, rowSelector])
|
||||
|
||||
const activeRef = useRef(active)
|
||||
useLayoutEffect(() => {
|
||||
@@ -295,8 +310,11 @@ export function useListWindow({
|
||||
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
if (!coversViewport(layoutNow, top)) setAnchorTop(top)
|
||||
}, [geometry, heights, position])
|
||||
if (!coversViewport(layoutNow, top)) {
|
||||
captureVisibleFocus(current)
|
||||
setAnchorTop(top)
|
||||
}
|
||||
}, [captureVisibleFocus, geometry, heights, position])
|
||||
|
||||
// Row and viewport measurement.
|
||||
useLayoutEffect(() => {
|
||||
@@ -391,6 +409,20 @@ export function useListWindow({
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
}
|
||||
// Height estimates can move a just-focused row past the viewport edge after
|
||||
// keyboard navigation. Only follow focus that was visible before remeasurement.
|
||||
const focused = visibleFocusRef.current
|
||||
visibleFocusRef.current = undefined
|
||||
if (windowed && focused?.isConnected && focused.contains(document.activeElement)) {
|
||||
const rect = focused.getBoundingClientRect()
|
||||
const top = viewportTop(container)
|
||||
const height = container?.clientHeight ?? window.innerHeight
|
||||
const delta = rect.bottom > top + height ? rect.bottom - top - height : Math.min(0, rect.top - top)
|
||||
if (Math.abs(delta) >= 0.5) {
|
||||
if (container) container.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
}
|
||||
const layoutNow = layoutRef.current
|
||||
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
|
||||
const { top } = position(current)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { expect, it } from 'vitest'
|
||||
it('preserves virtualized disclosures with native Electron input and bounded large-history DOM', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-expansion-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
const source = process.env.GB_HISTORY_SOURCE
|
||||
const source = process.env.GB_EXPANSION_SOURCE ?? process.env.GB_HISTORY_SOURCE
|
||||
const denseSource = process.env.GB_EXPANSION_DENSE_SOURCE
|
||||
const server = await createServer({
|
||||
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
|
||||
|
||||
@@ -88,6 +88,7 @@ it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
resolve('tests/support/supervisor-layout-driver.mjs'),
|
||||
driver
|
||||
)
|
||||
await copyFile(resolve('tests/support/supervisor-selection-driver.mjs'), join(directory, 'supervisor-selection-driver.mjs'))
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [driver], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
|
||||
@@ -10,11 +10,23 @@ app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
|
||||
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
win.webContents.on('console-message', event => {
|
||||
if (event.level === 'error') { errors.push(event.message); console.error('RendererError', event.message) }
|
||||
})
|
||||
const evaluate = source => win.webContents.executeJavaScript(source)
|
||||
const started = Date.now()
|
||||
const mark = phase => {
|
||||
console.log('MessageExpansionPhase ' + JSON.stringify({ phase, elapsedMs: Date.now() - started }))
|
||||
}
|
||||
const waitFor = source => evaluate(`new Promise((resolve, reject) => {
|
||||
const deadline = performance.now() + 10000;
|
||||
const check = () => { if (${source}) resolve(true); else if (performance.now() > deadline) reject(new Error(${JSON.stringify(source)})); else requestAnimationFrame(check); }; check(); })`)
|
||||
let frame;
|
||||
const timeout = setTimeout(() => { cancelAnimationFrame(frame); reject(new Error(${JSON.stringify(source)})); }, 10000);
|
||||
const check = () => {
|
||||
try {
|
||||
if (${source}) { clearTimeout(timeout); resolve(true); }
|
||||
else frame = requestAnimationFrame(check);
|
||||
} catch (error) { clearTimeout(timeout); reject(error); }
|
||||
}; check(); })`)
|
||||
const frames = () => evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))')
|
||||
const click = async selector => {
|
||||
await evaluate(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'center'})`)
|
||||
@@ -26,12 +38,14 @@ app.whenReady().then(async () => {
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
|
||||
}
|
||||
for (const count of [2000, 10000]) {
|
||||
for (const count of process.env.GB_EXPANSION_SUPERVISOR_ONLY ? [] : [2000, 10000]) {
|
||||
mark(`history-${count}-load`)
|
||||
await win.loadURL(process.env.GB_EXPANSION_URL + '?count=' + count)
|
||||
win.focus()
|
||||
win.webContents.focus()
|
||||
await waitFor('document.hasFocus() && document.querySelector("[data-message-id=m0] details")')
|
||||
await evaluate('document.fonts.ready.then(() => true)')
|
||||
mark(`history-${count}-sweep`)
|
||||
const untouched = await evaluate(`(async () => {
|
||||
const start = performance.now(), visited = new Set(); let maxRows = 0;
|
||||
for (let index = 0; index < ${count}; index += Math.floor(${count} / 200)) {
|
||||
@@ -55,6 +69,7 @@ app.whenReady().then(async () => {
|
||||
if (process.env.GB_EXPANSION_DENSE_SOURCE) assert.ok(untouched.entries >= untouched.visited, JSON.stringify(untouched))
|
||||
else if (!process.env.GB_HISTORY_SOURCE) assert.equal(untouched.entries, 0)
|
||||
const times = []
|
||||
mark(`history-${count}-disclosures`)
|
||||
let maxRows = 0
|
||||
let maxNodes = 0
|
||||
const visit = async index => {
|
||||
@@ -110,12 +125,15 @@ app.whenReady().then(async () => {
|
||||
navigationTwoFramesMedianMs: times[Math.floor(times.length/2)], maxRows, maxNodes, restored, untouched, changedEntries, afterDeletion,
|
||||
electron: process.versions.electron, platform: process.platform, arch: process.arch }))
|
||||
}
|
||||
mark('supervisor-load')
|
||||
await win.loadURL(process.env.GB_EXPANSION_URL + '?supervisor=1')
|
||||
mark('supervisor-source-ready')
|
||||
await waitFor('document.querySelector(".supervisor-workspace__detail .link-button")')
|
||||
await click('.supervisor-workspace__detail .link-button')
|
||||
await waitFor('typeof window.finishSource === "function"')
|
||||
await click('#change-language')
|
||||
await waitFor('document.documentElement.lang === "en-US" && document.querySelector(".supervisor-workspace__detail .link-button")')
|
||||
mark('supervisor-language-refresh')
|
||||
await waitFor('document.documentElement.lang === "en-US" && document.querySelector(".supervisor-workspace__detail .link-button")?.textContent.includes("Synthetic source 2")')
|
||||
await evaluate('window.finishSource()')
|
||||
await frames()
|
||||
const stuck = await evaluate('document.querySelector(".supervisor-workspace__detail .link-button").disabled')
|
||||
@@ -123,6 +141,7 @@ app.whenReady().then(async () => {
|
||||
assert.equal(await evaluate('document.body.innerText.includes("Stale content")'), false)
|
||||
console.log('MessageExpansion ' + JSON.stringify({ scenario: 'supervisor-invalidation', version: process.env.GB_HISTORY_SOURCE ? 'baseline' : 'fixed', stuck }))
|
||||
assert.deepEqual(errors, [])
|
||||
mark('complete')
|
||||
win.destroy()
|
||||
app.quit()
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
|
||||
@@ -57,15 +57,21 @@ function Fixture() {
|
||||
const supervisor = new URLSearchParams(location.search).has('supervisor')
|
||||
if (supervisor) {
|
||||
const at = '2026-10-01T00:00:00.000Z'
|
||||
let graphReads = 0
|
||||
Object.assign(window, { goodbuddy: { supervision: {
|
||||
overview: async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Synthetic review', changeDigest: '',
|
||||
createdAt: at, scope: { kind: 'global' }, timeRange: { from: at, to: at }, openItems: [] }],
|
||||
graph: async () => ({ storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: at }],
|
||||
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Synthetic source', occurred_at: at }],
|
||||
eventSources: [{ event_id: 'event', source_id: 'source' }] }),
|
||||
graph: async () => {
|
||||
const read = ++graphReads
|
||||
// Keep the replacement read pending long enough to expose partial-state waits.
|
||||
if (read > 1) await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return { storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: at }],
|
||||
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: `Synthetic source ${read}`, occurred_at: at }],
|
||||
eventSources: [{ event_id: 'event', source_id: 'source' }] }
|
||||
},
|
||||
source: () => new Promise(resolve => Object.assign(window, { finishSource: () => resolve({ title: 'Synthetic source', content: 'Stale content', occurredAt: at }) }))
|
||||
} } })
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(<UiLocaleProvider>{supervisor
|
||||
createRoot(document.getElementById('root')!).render(<UiLocaleProvider initialPreference="zh-CN">{supervisor
|
||||
? <><button id="change-language" onClick={() => void changeUiLocale('en-US')}>Change language</button><SupervisorWorkspace tab="graph" /></>
|
||||
: <Fixture />}</UiLocaleProvider>)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import { validateSupervisorSelection } from './supervisor-selection-driver.mjs'
|
||||
|
||||
const directory = process.env.GOODBUDDY_SUPERVISOR_DIRECTORY
|
||||
const artifacts = process.env.GOODBUDDY_SUPERVISOR_ARTIFACTS || directory
|
||||
@@ -63,6 +64,12 @@ app
|
||||
await settle()
|
||||
}
|
||||
const reports = []
|
||||
if (process.env.GOODBUDDY_SUPERVISOR_WINDOWING) {
|
||||
await validateSupervisorSelection(win, js, wait, process.env.GOODBUDDY_SUPERVISOR_URL)
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('Supervisor source Electron selection, history, source and edit interactions passed')
|
||||
win.destroy(); app.quit(); return
|
||||
}
|
||||
const selectHistory = async (id) => {
|
||||
await js('document.querySelector(".supervisor-workspace__result-navigation button").click()')
|
||||
await wait('!!document.querySelector(".supervisor-workspace__history-menu")')
|
||||
@@ -164,7 +171,7 @@ app
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
await js('document.querySelector("#supervisor-tab-graph").click()')
|
||||
await selectHistory(resultId)
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && document.querySelectorAll(".supervisor-workspace__list-panel > button").length === 21')
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && document.querySelectorAll(".supervisor-workspace__list-panel button").length === 21')
|
||||
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
|
||||
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21')
|
||||
await js('document.fonts.ready')
|
||||
@@ -196,7 +203,7 @@ app
|
||||
context:box(document.querySelector('.supervisor-workspace__review-context')),toolbar:box(toolbar),views:box(views),
|
||||
canvas:box(document.querySelector('.story-graph-3d__gl')),gaps:buttons.slice(1).map((b,i)=>b.left-buttons[i].right),
|
||||
segmentPadding:getComputedStyle(segment).paddingTop,
|
||||
events:document.querySelectorAll('.supervisor-workspace__list-panel > button').length,
|
||||
events:document.querySelectorAll('.supervisor-workspace__list-panel button').length,
|
||||
bounds:JSON.parse(document.documentElement.dataset.sceneBounds),
|
||||
scene:JSON.parse(document.documentElement.dataset.helix).dots.length,
|
||||
clusters:JSON.parse(document.documentElement.dataset.helix).clusters};
|
||||
@@ -212,8 +219,8 @@ app
|
||||
reports.push({ theme, ...boxes, colors: await checkSpiral() })
|
||||
if (width === 1440 && !process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT) await writeFile(join(artifacts, 'portable-spiral.png'), (await win.webContents.capturePage()).toPNG())
|
||||
}
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel > button").click()')
|
||||
await wait('document.querySelector(".supervisor-workspace__list-panel > button")?.getAttribute("aria-pressed") === "true"')
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel button").click()')
|
||||
await wait('document.querySelector(".supervisor-workspace__list-panel button")?.getAttribute("aria-pressed") === "true"')
|
||||
// The pre-assignment case uses the same real events with an explicitly empty membership response.
|
||||
await js(`window.goodbuddy.supervision.stories = async () => ({stories:[],experiences:[],unassigned:21,canUndo:false}); document.querySelector('.supervisor-workspace__review-context > .secondary-button').click()`)
|
||||
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21 && document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
@@ -317,7 +324,7 @@ app
|
||||
win.setContentSize(1024, 900)
|
||||
await settle()
|
||||
assert(await js('!document.querySelector(".supervisor-workspace__detail").getClientRects().length'), 'Medium details closed by default')
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel > button").click()')
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel button").click()')
|
||||
await wait('document.activeElement === document.querySelector(".supervisor-workspace__detail")')
|
||||
assert(await js(`(() => { const panel = document.querySelector('.supervisor-workspace__detail').getBoundingClientRect();
|
||||
const graph = document.querySelector('.supervisor-workspace__graph-layout').getBoundingClientRect();
|
||||
@@ -984,6 +991,7 @@ app
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
await validateSupervisorSelection(win, js, wait, process.env.GOODBUDDY_SUPERVISOR_URL)
|
||||
await open(false)
|
||||
await js(
|
||||
'document.querySelector(".supervisor-workspace__canvas-tools .link-button").click()'
|
||||
@@ -1176,6 +1184,9 @@ app
|
||||
for (const width of [1440, 1024, 390]) {
|
||||
win.setContentSize(width, 1100)
|
||||
await settle()
|
||||
await js(`(() => {const list=document.querySelector('.supervisor-workspace__list-panel'); list.scrollIntoView({block:'end'}); list.scrollTop=list.scrollHeight;list.dispatchEvent(new Event('scroll'))})()`)
|
||||
await wait('!!document.querySelector("[data-list-window-row=event-79] button")')
|
||||
await settle()
|
||||
const scrolling = await js(`(() => {
|
||||
const sidebar = document.querySelector('.supervisor-workspace__graph-list');
|
||||
const list = sidebar.querySelector('[role=tabpanel]');
|
||||
@@ -1185,7 +1196,7 @@ app
|
||||
const pageHeight = shell.scrollHeight, pageTop = shell.scrollTop;
|
||||
list.scrollTop = list.scrollHeight;
|
||||
const last = list.lastElementChild.getBoundingClientRect();
|
||||
const button = list.querySelector('button:last-of-type');
|
||||
const button = list.querySelector('[data-list-window-row="event-79"] button');
|
||||
const b = button.getBoundingClientRect(), r = list.getBoundingClientRect();
|
||||
return { width: innerWidth, height: sidebar.getBoundingClientRect().height, canvasHeight: canvas.getBoundingClientRect().height,
|
||||
clientHeight: list.clientHeight, scrollHeight: list.scrollHeight, scrollTop: list.scrollTop,
|
||||
@@ -1275,9 +1286,10 @@ app
|
||||
'Selected relation endpoints must remain visible'
|
||||
)
|
||||
await js('document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[0].click()')
|
||||
await js(`document.querySelector('.supervisor-workspace__list-panel').dispatchEvent(new KeyboardEvent('keydown',{key:'End',bubbles:true}))`)
|
||||
await settle()
|
||||
const target = await js(
|
||||
`(() => { const e = document.querySelectorAll('.supervisor-workspace__list-panel button')[79]; e.scrollIntoView({block:'center'}); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }; })()`
|
||||
`(() => { const e = document.querySelector('[data-list-window-row="event-79"] button'); e.scrollIntoView({block:'center'}); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }; })()`
|
||||
)
|
||||
assert(target.hit, 'Last event is not reachable')
|
||||
win.webContents.sendInputEvent({
|
||||
@@ -1300,12 +1312,7 @@ app
|
||||
`!!document.querySelector('.supervisor-workspace__map [aria-label="模拟事件 80"][aria-pressed=true]')`
|
||||
)
|
||||
)
|
||||
assert.equal(
|
||||
await js(
|
||||
'document.querySelectorAll(".supervisor-workspace__list-panel button").length'
|
||||
),
|
||||
80
|
||||
)
|
||||
assert(await js('document.querySelectorAll(".supervisor-workspace__list-panel button").length < 80'), 'Dense list is windowed without losing the last event')
|
||||
const positions = await js(
|
||||
'[...document.querySelectorAll(".supervisor-workspace__node:not(.supervisor-workspace__entity) circle")].map(e => [e.getAttribute("cx"), e.getAttribute("cy")].join())'
|
||||
)
|
||||
|
||||
@@ -141,12 +141,13 @@ const stories = [
|
||||
const story = stories[Number(params.get('story') ?? 0)] ?? stories[0]
|
||||
const titles = story.events
|
||||
const labels = story.entities
|
||||
const dense = params.has('dense')
|
||||
const windowed = params.has('windowed')
|
||||
const dense = params.has('dense') || windowed
|
||||
const long = params.has('long')
|
||||
const state = params.get('state')
|
||||
const graph: SupervisionGraphView = {
|
||||
storyLine: { id: 'fixture', scope_json: '{"kind":"global"}' },
|
||||
events: Array.from({ length: dense ? 80 : 8 }, (_, index) => ({
|
||||
events: Array.from({ length: windowed ? 600 : dense ? 80 : 8 }, (_, index) => ({
|
||||
id: `event-${index}`,
|
||||
title: dense
|
||||
? `模拟事件 ${index + 1}`
|
||||
@@ -158,7 +159,7 @@ const graph: SupervisionGraphView = {
|
||||
? '2026-09-21T08:00:00.000Z'
|
||||
: `2026-09-${String(14 + index).padStart(2, '0')}T08:00:00.000Z`
|
||||
})),
|
||||
entities: Array.from({ length: dense ? 40 : 6 }, (_, index) => ({
|
||||
entities: Array.from({ length: windowed ? 600 : dense ? 40 : 6 }, (_, index) => ({
|
||||
id: `entity-${index}`,
|
||||
canonical_label: dense
|
||||
? `模拟实体 ${index + 1}`
|
||||
@@ -193,6 +194,12 @@ const graph: SupervisionGraphView = {
|
||||
source_id: 'source-1'
|
||||
}))
|
||||
}
|
||||
if (windowed) {
|
||||
graph.relations = Array.from({ length: 600 }, (_, index) => ({ ...graph.relations[0]!, id: `relation-${index}`,
|
||||
from_entity_id: `entity-${index}`, to_entity_id: `entity-${(index + 1) % 600}` }))
|
||||
graph.eventEntities = graph.events.map((event, index) => ({ event_id: event.id, entity_id: `entity-${index}` }))
|
||||
graph.eventSources = graph.events.map(event => ({ event_id: event.id, source_id: 'source-1' }))
|
||||
}
|
||||
if (params.has('short')) {
|
||||
graph.events = graph.events.slice(0, 1)
|
||||
graph.entities = graph.entities.slice(0, 1)
|
||||
@@ -310,6 +317,20 @@ if (params.has('portable')) {
|
||||
run: async () => { throw new Error('Read-only acceptance must not run reviews') }
|
||||
})
|
||||
}
|
||||
if (windowed) {
|
||||
const api = window.goodbuddy.supervision
|
||||
const calls = { graph: 0, source: 0, edits: 0 }
|
||||
const updateCalls = () => { document.documentElement.dataset.supervisorCalls = JSON.stringify(calls) }
|
||||
const readGraph = api.graph, readSource = api.source
|
||||
api.graph = async input => { calls.graph++; updateCalls(); return readGraph(input) }
|
||||
api.source = async input => { calls.source++; updateCalls(); return readSource(input) }
|
||||
api.entityAction = async input => {
|
||||
calls.edits++; updateCalls()
|
||||
const entity = graph.entities.find(item => item.id === input.entityId)
|
||||
if (entity && input.action === 'revise' && input.label) entity.canonical_label = input.label
|
||||
}
|
||||
updateCalls()
|
||||
}
|
||||
const noop = async () => {}
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<UiLocaleProvider initialPreference="zh-CN">
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// Real production components, native keyboard and mouse input; only the bridge data is simulated.
|
||||
export async function validateSupervisorSelection(win, js, wait, url) {
|
||||
win.show(); win.focus(); win.setContentSize(1440, 1100)
|
||||
await win.loadURL(url + '?windowed=1&recap=1')
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
assert.equal(await js('JSON.parse(document.documentElement.dataset.supervisorCalls).graph'), 0)
|
||||
const frames = () => js('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))')
|
||||
const click = async selector => {
|
||||
win.focus(); win.webContents.focus()
|
||||
const point = await js(`(() => { const e=document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'nearest'}); const r=e.getBoundingClientRect(); return {x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)} })()`)
|
||||
win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...point})
|
||||
win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...point})
|
||||
await frames()
|
||||
}
|
||||
const key = async (keyCode, modifiers = []) => {
|
||||
win.focus(); win.webContents.focus()
|
||||
await wait('document.hasFocus()')
|
||||
win.webContents.sendInputEvent({type:'keyDown',keyCode,modifiers})
|
||||
if (keyCode === 'Enter') win.webContents.sendInputEvent({type:'char',keyCode:'\r'})
|
||||
win.webContents.sendInputEvent({type:'keyUp',keyCode,modifiers})
|
||||
await frames()
|
||||
}
|
||||
const panel = '.supervisor-workspace__selection-list'
|
||||
await click('#supervisor-tab-graph')
|
||||
await wait(`!!document.querySelector('${panel}') && document.querySelector('.supervisor-workspace').getAttribute('aria-busy') === 'false'`)
|
||||
for (const kind of ['event', 'entity', 'relation']) {
|
||||
await js(`document.querySelector('.supervisor-workspace__category').value='${kind}';document.querySelector('.supervisor-workspace__category').dispatchEvent(new Event('change',{bubbles:true}))`)
|
||||
await wait(`document.querySelector('${panel}')?.id.endsWith('-${kind}')`)
|
||||
// Select a middle record so selection/focus pins cannot accidentally keep either endpoint.
|
||||
await js(`(() => {const p=document.querySelector('${panel}'); p.scrollTop=(p.scrollHeight-p.clientHeight)/2;p.dispatchEvent(new Event('scroll'))})()`)
|
||||
await wait(`(() => {const button=document.querySelector('[data-list-window-row="${kind}-300"] button');if(!button)return false;button.click();return true})()`)
|
||||
await wait(`document.querySelector('[data-list-window-row="${kind}-300"] button')?.getAttribute('aria-pressed') === 'true'`)
|
||||
await frames()
|
||||
await js(`(() => {
|
||||
const p=document.querySelector('${panel}');
|
||||
const stops=[...document.querySelectorAll('button,input,select,textarea,a[href],[tabindex],summary')]
|
||||
.filter(e=>e.tabIndex>=0 && !e.disabled && e.getClientRects().length && !e.closest('[hidden],[inert]'));
|
||||
const index=stops.indexOf(p);
|
||||
window.selectionEntryBefore=stops[index-1];
|
||||
window.selectionEntryAfter=stops.slice(index+1).find(e=>!p.contains(e));
|
||||
window.selectionEntryBefore.focus();
|
||||
p.scrollTop=(p.scrollHeight-p.clientHeight)/2;p.dispatchEvent(new Event('scroll'));
|
||||
})()`)
|
||||
await frames()
|
||||
assert(await js(`document.querySelector('${panel}').scrollTop > 1000`), 'Forward entry starts with the viewport in the middle')
|
||||
await key('Tab')
|
||||
assert(await js(`document.activeElement===document.querySelector('${panel}')`), 'Forward Tab reaches the panel first')
|
||||
await key('Tab')
|
||||
const forwardEntry = await js("document.activeElement.closest('[data-list-window-row]')?.dataset.listWindowRow")
|
||||
const forwardVisible = await js(`(() => {const p=document.querySelector('${panel}').getBoundingClientRect(),r=document.activeElement.getBoundingClientRect();return r.top>=p.top-1 && r.bottom<=p.bottom+1})()`)
|
||||
await key('Tab', ['shift']); await key('Tab', ['shift'])
|
||||
const backwardExit = await js('document.activeElement===window.selectionEntryBefore')
|
||||
await js(`(() => {window.selectionEntryAfter.focus();const p=document.querySelector('${panel}');p.scrollTop=(p.scrollHeight-p.clientHeight)/2;p.dispatchEvent(new Event('scroll'))})()`)
|
||||
await frames()
|
||||
assert(await js(`document.querySelector('${panel}').scrollTop > 1000`), 'Backward entry starts with the viewport in the middle')
|
||||
await key('Tab', ['shift'])
|
||||
const backwardEntry = await js("document.activeElement.closest('[data-list-window-row]')?.dataset.listWindowRow")
|
||||
const backwardVisible = await js(`(() => {const p=document.querySelector('${panel}').getBoundingClientRect(),r=document.activeElement.getBoundingClientRect();return r.top>=p.top-1 && r.bottom<=p.bottom+1})()`)
|
||||
await key('Tab')
|
||||
const forwardExit = await js('document.activeElement===window.selectionEntryAfter')
|
||||
assert.deepEqual({ forwardEntry, backwardEntry, backwardExit, forwardExit, forwardVisible, backwardVisible },
|
||||
{ forwardEntry: `${kind}-0`, backwardEntry: `${kind}-599`, backwardExit: true, forwardExit: true, forwardVisible: true, backwardVisible: true },
|
||||
'External sequential entry/exit follows the full logical list without a focus trap')
|
||||
await js(`document.querySelector('${panel}').focus()`)
|
||||
await key('End')
|
||||
await wait(`document.activeElement?.closest('[data-list-window-row]')?.dataset.listWindowRow === '${kind}-599'`)
|
||||
assert(await js(`document.querySelectorAll('${panel} button').length <= 123`), 'DOM must be bounded')
|
||||
const focusedBounds = await js(`(() => {const panel=document.querySelector('${panel}'),p=panel.getBoundingClientRect(),r=document.activeElement.getBoundingClientRect();return {top:r.top,bottom:r.bottom,panelTop:p.top,panelBottom:p.bottom,scrollTop:panel.scrollTop,scrollHeight:panel.scrollHeight}})()`)
|
||||
assert(focusedBounds.top >= focusedBounds.panelTop - 1 && focusedBounds.bottom <= focusedBounds.panelBottom + 1, `End must focus a visible last row: ${kind} ${JSON.stringify(focusedBounds)}`)
|
||||
await key('Enter')
|
||||
assert(await js(`document.querySelector('[data-list-window-row="${kind}-599"] button').getAttribute('aria-pressed')==='true'`))
|
||||
await js(`document.querySelector('${panel}').focus()`)
|
||||
await key('Home')
|
||||
await wait(`document.activeElement?.closest('[data-list-window-row]')?.dataset.listWindowRow === '${kind}-0'`)
|
||||
for (let i=0;i<65;i++) await key('Tab')
|
||||
assert.equal(await js("document.activeElement.closest('[data-list-window-row]').dataset.listWindowRow"), `${kind}-65`, 'Tab traverses unmounted rows')
|
||||
await key('Tab', ['shift'])
|
||||
assert.equal(await js("document.activeElement.closest('[data-list-window-row]').dataset.listWindowRow"), `${kind}-64`)
|
||||
// A focused row stays mounted during scrollbar jumps; the new viewport must fill too.
|
||||
await js(`document.querySelector('${panel}').scrollTop=20000;document.querySelector('${panel}').dispatchEvent(new Event('scroll'))`)
|
||||
await frames()
|
||||
assert.equal(await js("document.activeElement.closest('[data-list-window-row]').dataset.listWindowRow"), `${kind}-64`)
|
||||
assert(await js(`(() => {const p=document.querySelector('${panel}'),r=p.getBoundingClientRect();return [...p.querySelectorAll('[data-list-window-row]')].some(e=>{const b=e.getBoundingClientRect();return b.bottom>r.top && b.top<r.bottom})})()`), 'Scrolled viewport must have rows')
|
||||
assert(await js(`document.querySelectorAll('${panel} button').length <= 123`))
|
||||
}
|
||||
// Metadata does not trigger body reads. Opening the source still shows its complete saved snapshot.
|
||||
assert.equal(await js('JSON.parse(document.documentElement.dataset.supervisorCalls).source'), 0)
|
||||
await js(`document.querySelector('.supervisor-workspace__category').value='event';document.querySelector('.supervisor-workspace__category').dispatchEvent(new Event('change',{bubbles:true}))`)
|
||||
await frames()
|
||||
await click('.supervisor-workspace__detail > button.link-button')
|
||||
await wait('document.querySelector(".supervisor-workspace__source-content") || document.body.textContent.includes("视觉 fixture,不是真实用户数据。")')
|
||||
assert.equal(await js('JSON.parse(document.documentElement.dataset.supervisorCalls).source'), 1)
|
||||
await click('#supervisor-tab-overview')
|
||||
await click('#supervisor-tab-graph')
|
||||
assert.equal(await js('JSON.parse(document.documentElement.dataset.supervisorCalls).graph'), 1, 'Warm tab switch keeps the graph')
|
||||
await click('.supervisor-workspace__result-navigation button')
|
||||
await click('.supervisor-workspace__history-menu button[value="older-result"]')
|
||||
await wait('document.querySelector(".supervisor-workspace").getAttribute("aria-busy")==="false"')
|
||||
assert.equal(await js('JSON.parse(document.documentElement.dataset.supervisorCalls).graph'), 2)
|
||||
assert(!await js('document.body.textContent.includes("视觉 fixture,不是真实用户数据。")'), 'History clears the old source')
|
||||
await js(`document.querySelector('.supervisor-workspace__category').value='entity';document.querySelector('.supervisor-workspace__category').dispatchEvent(new Event('change',{bubbles:true}))`)
|
||||
await frames()
|
||||
await js(`document.querySelector('${panel}').focus()`)
|
||||
await key('End'); await key('Enter')
|
||||
await click('.supervisor-workspace__detail > .supervisor-workspace__actions button:nth-child(2)')
|
||||
await click('.supervisor-workspace__detail form input')
|
||||
await key('a', ['control'])
|
||||
for (const character of 'Edited entity') win.webContents.sendInputEvent({type:'char',keyCode:character})
|
||||
await click('.supervisor-workspace__detail form button')
|
||||
await wait('JSON.parse(document.documentElement.dataset.supervisorCalls).edits===1 && document.querySelector(".supervisor-workspace").getAttribute("aria-busy")==="false"')
|
||||
assert.equal(await js('JSON.parse(document.documentElement.dataset.supervisorCalls).graph'), 3)
|
||||
assert(await js('document.querySelector("[data-list-window-row=entity-599]").textContent.includes("Edited entity")'))
|
||||
// Narrow layout still fills the scroll viewport without clipping full labels.
|
||||
win.setContentSize(390, 800)
|
||||
await frames()
|
||||
await js(`document.querySelector('${panel}').focus()`)
|
||||
await key('End')
|
||||
assert(await js('document.documentElement.scrollWidth <= innerWidth'))
|
||||
assert(await js(`document.querySelectorAll('${panel} button').length <= 123`))
|
||||
win.setContentSize(1440, 1100)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ void app.whenReady().then(async () => {
|
||||
let pollsAborted = 0
|
||||
let pollingUnavailable = false
|
||||
let pollingConflict = false
|
||||
let sendingForbidden = false
|
||||
const originalNetFetch = net.fetch
|
||||
// This is the only Telegram substitution: production driver construction and IPC stay intact.
|
||||
net.fetch = async (input, init) => {
|
||||
@@ -71,7 +72,10 @@ void app.whenReady().then(async () => {
|
||||
result = updates.splice(0)
|
||||
break
|
||||
case 'sendChatAction': result = true; break
|
||||
case 'sendMessage': result = { message_id: 1000 }; break
|
||||
case 'sendMessage':
|
||||
if (sendingForbidden) return new Response(JSON.stringify({ ok: false, error_code: 403,
|
||||
description: `https://api.telegram.org/bot${token}/sendMessage` }), { status: 403 })
|
||||
result = { message_id: 1000 }; break
|
||||
default: throw new Error(`Unexpected Telegram method: ${method}`)
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true, result }), { headers: { 'content-type': 'application/json' } })
|
||||
@@ -223,6 +227,28 @@ void app.whenReady().then(async () => {
|
||||
await click('#channel-settings-panel-telegram input[role="switch"]')
|
||||
await click('button.primary-button')
|
||||
await wait(() => run(`!document.querySelector('button.primary-button').disabled && document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected'`), 're-enabled before clearing credentials')
|
||||
sendingForbidden = true
|
||||
database.enqueueChannelResult({ channel: 'telegram', eventId: 'delivery-status-fixture',
|
||||
conversationId: '123456:700001', recipientId: '700001', status: 'completed', output: 'Synthetic saved reply' })
|
||||
pollingConflict = true
|
||||
await wait(() => run(`document.querySelector('.channel-settings-card [role="alert"]')?.textContent.includes('polling conflict')`), 'conflict before saved reply retry')
|
||||
pollingConflict = false
|
||||
await click('button.primary-button')
|
||||
await wait(() => run(`document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected' && document.querySelector('.channel-settings-card [role="alert"]')?.textContent.includes('Telegram sending failed:')`), 'sending failure visible while polling stays connected')
|
||||
assert.equal(database.listUndeliveredChannelResults('telegram').find(entry => entry.message.eventId === 'delivery-status-fixture')?.state, 'terminal')
|
||||
const visibleFailure = await run<string>(`document.querySelector('.channel-settings-card [role="alert"]').textContent`)
|
||||
assert(visibleFailure.includes('forbidden'))
|
||||
assert(!visibleFailure.includes(token) && !visibleFailure.includes('api.telegram.org'))
|
||||
await fill('input[aria-label="Telegram Bot Token"]', 'unsaved-delivery-token')
|
||||
await fill('textarea[aria-label="Telegram allowed sender IDs"]', '700001\n700004')
|
||||
const pollsBefore = calls.filter(call => call.method === 'getUpdates').length
|
||||
await wait(() => calls.filter(call => call.method === 'getUpdates').length >= pollsBefore + 2, 'healthy polls after delivery failure')
|
||||
assert.equal(await run(`document.querySelector('.channel-settings-card [role="alert"]').textContent`), visibleFailure)
|
||||
assert.equal(await run(`document.querySelector('input[aria-label="Telegram Bot Token"]').value`), 'unsaved-delivery-token')
|
||||
assert.equal(await run(`document.querySelector('textarea[aria-label="Telegram allowed sender IDs"]').value`), '700001\n700004')
|
||||
await win.reload()
|
||||
await wait(() => run(`document.querySelector('.channel-settings-card [role="alert"]')?.textContent.includes('Telegram sending failed:')`), 'delivery error restored from snapshot')
|
||||
sendingForbidden = false
|
||||
const projectBeforeClear = database.getProject(conversation.projectId!)
|
||||
const historyBeforeClear = database.getConversation(conversation.id)
|
||||
await click('#channel-settings-panel-telegram input[type="checkbox"]:not([role="switch"])')
|
||||
@@ -238,6 +264,7 @@ void app.whenReady().then(async () => {
|
||||
console.log(JSON.stringify({ telegramSmoke: 'passed', uiConfigSaveTest: true, productionPreloadIpc: true,
|
||||
productionExecutor: true, desktopConversationPersisted: true, reloadRestored: true,
|
||||
disableCancelsModel: true, liveStatusPreservesDrafts: true, unchangedSaveRecoversConflict: true, clearTokenDisablesAndPreservesHistory: true,
|
||||
sendingFailureRetainsPollingStateAndDrafts: true,
|
||||
modelCalls: payloads.length, telegramReplies: replies.length,
|
||||
pollsAborted, externalNetworkCalls: 0 }))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user