@mzzsfy/dsh-session-manager 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -11
- package/package.json +1 -1
- package/src/client.js +216 -26
- package/src/core.mjs +72 -0
- package/src/history-cache.mjs +38 -5
- package/src/index.js +7 -2
- package/test/client-scope.test.mjs +54 -0
- package/test/client-toast.test.mjs +31 -2
- package/test/core.test.mjs +140 -0
- package/test/history-cache.test.mjs +84 -0
- package/test/index.test.mjs +37 -2
- package/test/parity.test.mjs +60 -1
package/README.md
CHANGED
|
@@ -4,13 +4,17 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
|
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
|
-
- 历史输入浮层:输入焦点处按 `Alt+↑` 唤起浮层(平时界面零占位)
|
|
8
|
-
-
|
|
7
|
+
- 历史输入浮层:输入焦点处按 `Alt+↑` 唤起浮层(平时界面零占位),浏览并回填历史输入。client 半区挂官方 `conversation.input.dock` 插槽(渲染为零高度锚点);条目点选或 `↑/↓` 选择(高亮行带左侧色条、自动滚入可视区)、`Enter` 填入、`Esc`/外点关闭(捕获阶段拦截,先于 Lexical 光标移动;IME 组合中跳过);浮层与输入框同宽对齐、不透明实底,选中态高亮强化;回填一律走宿主公共契约 `inputActions.setDraft`(不直改编辑器 DOM)。宿主缺该插槽或 `inputActions` 缺失时本功能整体不注册不渲染,快捷键与浮层均不存在,不影响其余能力。
|
|
8
|
+
- **范围导航**:浮层内 `←/→` 切换范围,范围序为 **常用 → 当前会话(默认落点)→ 本工作区 → 全部工作区**,`→` 向更大范围,`←` 返回收藏,边界停住;范围显示为浮层左上角徽标。
|
|
9
|
+
- **工作区持久缓存**:每工作区一个 JSON 文件(`~/.dsh/historyPrompt/<工作区>-<路径哈希>.json`,可辨认 + 防同名冲突),内容为该工作区聚合后的输入条目(带 `sid` 来源会话溯源)。浮层请求直接读缓存文件立即返回(毫秒级,响应 `{ inputs, aligned }`);**对齐**在后台进行:解压范围内最近会话产物(经官方 `sessionQuery.readSession`,仅 `source.kind === 'user'` 的人类输入,工具结果回填与插件注入排除)、与缓存合并去重后原子写回(临时文件 + 改名)。对齐时机:插件启动对齐最近会话覆盖的工作区、每次浮层请求触发(同工作区 30 秒节流)、**运行中会话参与对齐(输入实时追加)**。
|
|
10
|
+
- **范围语义**:`prompts` 用户显式收藏的常用提示词(全局单文件 `prompts.json`,不按工作区,详见收藏交互);`session` 该会话输入(缓存按 `sid` 过滤);`workspace` 同 cwd 工作区缓存;`global` 全部工作区缓存并集。
|
|
11
|
+
- **上限与首次对齐**:全部历史最多保留最新 200 条(`HISTORY_INPUT_LIMIT`)、单条截 2 万字符(`HISTORY_INPUT_MAX_CHARS`),聚合精确文本去重保留最新时间。首次(该工作区尚无缓存文件)`aligned=false`,client 显示「首次对齐历史中」提示并每 3 秒(`HISTORY_REPULL_MS`)静默重拉;单会话产物读取失败仅告警跳过;scope 非法或缺省回退 `session`。
|
|
12
|
+
- 常用提示词收藏:`prompts` 以外的范围,历史行鼠标悬停时行尾出现收藏星标(已收藏实星、未收藏空心),点击即收藏/取消(经 `POST /api/session-manager/prompts/toggle`),toast 反馈并同步星标;`prompts` 范围右上角「编辑」按钮进入编辑态:行内文本框直接改(Enter/失焦保存,实现为移除旧文本 + 收藏新文本)、行尾 `×` 删除、底部输入框 Enter 新增,「完成」退出;上限 `HISTORY_PROMPTS_MAX` 条,超出裁掉最旧;收藏与自动对齐的历史缓存互不覆写(工作区缓存列举跳过 prompts.json);global 范围读取走 stat 指纹缓存(`listWorkspaceCachesCached`),产物未变的文件不重复读盘;client 与 core 的 `HISTORY_SCOPES` 镜像由 parity 测试锁定。
|
|
9
13
|
- 历史浮层启停开关:低频功能,归档面板末行小尺寸开关(规约 switch 形态 `sm-histsw`,状态选择器锚定 `input[type=checkbox]`);值存宿主 settings(`historyEnabled`,默认启用,原生设置页与本面板同存储即时同值),读写经本插件 `GET/POST /api/session-manager/history-enabled`(client 写 settings 不经宿主 remotes,零版本脆弱依赖);浮层挂载拉取一次,停用即锚点/快捷键/浮层整体不渲染、键盘监听放行,变更刷新页面生效。
|
|
10
14
|
- 自动归档:host 半区对超期会话走官方 `workspace.archiveSession` 通道,三个触发源共用同一评估与门闩:新会话创建(`session/created`,按该会话所属工作区限定)、settings 就绪后的启动补扫(全量,清掉停机期间积压)、每日周期轮(全量,经宿主 timer 服务固定短 tick + 到期判断)。幂等:已归档会话不参与评估。阈值 `autoArchiveDays` 默认 7,`0` 关闭;周期间隔 `autoArchiveIntervalHours` 默认 24,`0` 关闭周期轮,变更经周期 tick 对账(关闭即时暂停、重启用最迟下个 tick 生效、缩短经锚定钳制前移,最长等新间隔而非等满旧周期)。评估筛选:createdAt 纯内存预筛 + 已归档跳过 + stat 后活跃度判定,locate 缺失(第三方后端)与产物不可读的会话一律跳过,宁可漏归档不可误归档。经 settings 命名空间 `session-manager` 注册(schema 拒绝负数与非整数)。timer 为软依赖:宿主定时服务不可用时仅周期轮停用,面板顶部提示,插件其余能力不受影响。
|
|
11
15
|
- 自动归档配置:归档面板「已删除」区下方的配置行,阈值天数与检查周期小时数两个数字输入,失焦或 Enter 单字段即时提交,非法输入前端拦截不发请求,服务端失败回读生效值回滚;读写经本插件 `GET/POST /api/session-manager/auto-archive`(POST 部分更新,仅接受出现且为非负整数的字段,空字段拒绝),值存宿主 settings 与评估逻辑同源,`0` = 关闭,GET 返回含默认回退的当前生效值。
|
|
12
|
-
- 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow`
|
|
13
|
-
- 归档与操作通知:host 归档动作经 `workspace.follow` 的 `archived` 增量帧到达 client
|
|
16
|
+
- 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow` 归档快照的交集,按更新时间倒序;主列表只展开最近 `ARCHIVE_PAGE_SIZE`(100)条,列表尾部「按工作区浏览」按钮直接进入二级视图查看全部(主列表不翻页,防归档集合无限增长拖垮渲染)。二级视图:无搜索词时按工作区分组(组间按组内最新活跃倒序,分组默认收起,展开即见组内首页并可继续翻页,`workspace` 假值并入「未分组」组);输入搜索词后平铺展示标题命中(不区分大小写子串,标题缺失回退会话 id 天然可搜)的全部归档会话并显示匹配计数,同样分页。行内标题前显示所属工作区徽标——映射优先走 workspace 账本(`sessionIds`,与官方 dsh-client-ui-workspace 的 workspaceBySession 同构),cwd 命中某登记工作区路径时回退该工作区标题(较官方「cwd basename 直接回退」收紧,防未登记目录名冒充工作区),均未命中显示「未分组」;工作区 title 缺失回退路径末段。镜像函数 client 与 core 双实现由 parity 测试锁定。行内支持取消归档与删除。
|
|
17
|
+
- 归档与操作通知:host 归档动作经 `workspace.follow` 的 `archived` 增量帧到达 client,集合差分得到新增会话,经公共通知依赖 `@mzzsfy/dsh-toast` 顶部居中显示带会话标题的文案:单个显示如「会话「修复登录页」已归档」,多个显示「有 N 个会话已归档:标题1、标题2」(N 恒为真实总数),标题数超过 `TOAST_MAX_TITLES` 只列前 `TOAST_MAX_TITLES` 个并以「 等」收尾,标题缺失、空白或行不存在回退会话 id;host 侧逐会话归档成功时写一条 info 日志(含会话 id)。面板操作(恢复 / 删除 / 重新挂载等)的成功与失败反馈同样经该依赖展示,失败提示常驻待确认。dsh-toast 为普通 npm 依赖由本插件 dependencies 声明随装(表层 manifest 不再单列,装载经 dsh fallback 沿 bundles 依赖闭包补链),其宿主占位条目由本插件 cordis.patch.yml 代挂。
|
|
14
18
|
- 删除:仅对已归档会话生效(运行中守卫先于执行,执行前 TOCTOU 复检,运行中的会话不可删除;OS 回收存在数百 ms 异步窗口,复检通过后仍可能恢复运行——此时回收已完成,响应附警告并落服务端日志),两段式确认(展示标题 / 日志体积,产物已缺失时以「产物已丢失」替代体积)后按 locate → trash → 台账 → detach → 归档清理单序执行;同 id 并发删除仅首个生效,其余以「正在删除中」拒绝。会话产物目录整体移入系统回收站(Windows PowerShell VisualBasic / macOS Finder / Linux gio;Windows 路径经环境变量传入 PowerShell,含空格或特殊字符的路径安全),可还原,不做直接删除降级。trash 成功即写入已删除台账(独立 storage domain `session_manager`,读改写经进程内互斥链串行化且单次操作带超时兜底,条目上限 200 裁最旧),记录会话 id 与产物原位置。产物**目录**已缺失的会话,仅当已归档或台账有记录(同 id 重删的残留收尾)时跳过回收与台账、仅解除列表关联,提示「产物已不存在,已完成列表清理」;两者皆无的缺失可能是新建会话尚未落盘,直接拒绝以防剥离活会话。
|
|
15
19
|
- 已删除分区与重挂载:面板「已删除」区列出台账条目(标题 / 原位置 / 删除时间)。用户在系统回收站将会话文件夹还原到原位置后点「重新挂载」,经官方 `workspace.attachSession` 挂回工作区列表并清除台账;「移除记录」幂等清除单条。还原动作本身始终由用户在系统回收站完成,插件不驱动系统回收站。
|
|
16
20
|
|
|
@@ -59,10 +63,22 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
|
|
|
59
63
|
- 删除:成功路径在 detach 后同样从归档集合移除该 id(域与快照同步),清理失败响应 partial 提示;detach 失败时不清理归档集合,保留归档资格供重试。删除后 `session.list` 的会话行按官方语义仅在宿主发出 `session/disposed`(经 `api-session/removed`)时移除;官方 `SessionStore` 无按 id dispose 的公开 API,本进程内打开过的会话被删除后,其列表行会以「未分组」形式残留,直至宿主重启(重启后 live 记录清空,持久层扫描因产物缺失自然不再列出)。残留行仍可再次执行删除:产物缺失路径即为此设计,完成列表关联清理并提示。重挂载复用该语义:删除后会话行短暂残留在 client 会话模型,「已删除」区标题在重连前显示原标题,重连后回退会话 id。
|
|
60
64
|
- 已删除台账:独立 storage domain `session_manager`(global 单列表),非 workspace 域、非审计层——存在意义仅为重挂载定位产物原位置。trash 成功即记录(位于 detach 前),台账失败仅降级重挂载便利,不回滚删除;同 id 再次删除替换旧条目。台账读改写经进程内互斥链串行化(并发删除不再有丢更新窗口),条目上限 200、超出裁最旧,防无界增长写放大。台账不随产物还原自动清除,以「重新挂载」或「移除记录」收尾;清空回收站后的残留条目无恢复价值,应手动移除。
|
|
61
65
|
- 评估的活跃时间:JSONL 后端以产物 mtime 作为最近活跃代理,updatedAt = max(createdAt, mtime);非 JSONL 后端退化为 createdAt。评估两级筛选:先用 createdAt 纯内存预筛(mtime 只会增大 updatedAt,createdAt 未超期必不超期),仅对预筛存活者做产物 stat / blank 探测,活跃会话零 IO。空白判定:live 会话 seq=0,冷会话以 JSONL 产物「仅 header 一行」判定,locate 不可用的后端按非空白处理。评估整体带超时兜底(宿主服务挂起时门闩必然复位,不会永久停摆);单会话归档失败仅告警不中断整轮。
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
+
- 无批量操作、无正文内容搜索(归档库仅标题子串检索)、无会话详情预览。
|
|
67
|
+
- 归档库的组织与检索:分组键为行投影的工作区标题,同名标题的不同工作区在库视图合并为一组(行投影不含工作区 id,不为分组扩展投影公共形态);搜索仅匹配标题,不做正文全文检索(复用 history-cache 解压基建的正文搜索属后续独立任务);分页粒度 100 为统一常量(`ARCHIVE_PAGE_SIZE`),搜索命中与组内展开共用同一分页机制,主列表固定首页不翻页;视图切换、展开与搜索状态均为面板内存态,离开设置页即复位,不做持久化。
|
|
68
|
+
- 自动归档评估门闩:评估进行中其他触发源(含周期轮与 session/created)的新触发直接丢弃,不做排队;漏掉的会话由下一次触发兜底,启动补扫与每日周期轮保证了积压会话最迟一个周期内归档。产物不可读(mtime 探测失败)的会话视为已删除,不参与归档,防止删除后的会话被重新归档复活。subagent 内部会话(`origin: 'subagent'`)永不参与归档:官方主列表即以 origin 排除 subagent(归档集合仅服务用户会话),误归档只产出无 title 回退 cwd 名的不可辨认面板行与无意义通知。timer 软依赖:宿主定时服务缺失或调用失败时周期轮停用,设置页显示降级提示(session/created 触发与启动补扫不受影响);timer 服务晚于插件激活时周期轮自动补武装。
|
|
69
|
+
- 归档提示以「连续两个 ready 快照」为差分启用条件:基线首装(启动与重连)不提示存量,重连基线携带的离期新增仍会提示。通知展示经公共依赖 `@mzzsfy/dsh-toast`(external require),本包不再自带通知 UI。client.js 为单文件自包含格式,守卫逻辑与 core.mjs 各存一份镜像,修改需两处同步(projectRows / archiveToastStep / projectDeletedRows / pageArchiveRows / groupArchiveRowsByWorkspace / filterArchiveRows 同规);test/parity.test.mjs 对各组镜像做行为级对照并锁定 client 侧确认态文案与 host MESSAGES 同值。client 已删除列表加载失败时保留旧数据并 console.warn,不渲染成「无记录」。路由错误响应:业务错误原样透传中文文案,系统级错误(带 fs 错误码,message 内嵌绝对路径)收敛为固定文案并落服务端日志。
|
|
70
|
+
- 历史输入的已知取舍:
|
|
71
|
+
- 对齐扫描各工作区最近 20 个会话(`HISTORY_SESSION_SCAN_LIMIT`,更早会话的输入不入缓存),解压发生在后台、永不阻塞浮层响应,代价是首次对齐期间列表为空、需等静默重拉。
|
|
72
|
+
- **解压不重复执行**:每会话提取结果持久化在工作区缓存 `extracts` 段并附产物 stat 指纹(mtimeMs+size),对齐时先 stat 比对、产物未变零解压,判断完全基于磁盘、跨重启生效;运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效。
|
|
73
|
+
- 启动零解压(历史直接读磁盘缓存,重启前的输入本就在 entries 里),全量对齐延迟 30 秒(`HISTORY_STARTUP_DELAY_MS`)再跑且只回溯最近 100 个会话(`HISTORY_STARTUP_SCAN_LIMIT`)覆盖的工作区。
|
|
74
|
+
- 对齐中连续解压占用超 200ms(`HISTORY_ALIGN_SLICE_MS`)即让出 100ms(`HISTORY_ALIGN_YIELD_MS`,zstd 解压为同步 CPU 操作,不设让出会让宿主启动阻塞数十秒);超 8MiB(`HISTORY_ALIGN_MAX_ARTIFACT_BYTES`)的巨产物跳过自动对齐(单次 readSession 内部同步解压不可让出,实时性让位于不卡主循环)。
|
|
75
|
+
- header 查找走 ctx 级 TTL 缓存(inputs 路由不每次全量扫描 listSessions)。
|
|
76
|
+
- 对齐为双车道:批量车道(窗口对齐)与焦点车道(单会话实时追加)各自串行互不等待,会话范围请求检测到产物指纹失配即触发焦点对齐并返回 aligned=false。
|
|
77
|
+
- 浮层打开期间固定节奏轮询当前范围(数据可能晚于首次响应到达,一次性重拉预算会让迟到数据永不出现);单会话首条数据可见预算 3s:会话范围路由触发焦点对齐后就地等待 2s(`HISTORY_FOCUS_WAIT_MS`),正常会话单次请求内拿到新数据,超时(巨产物)走轮询流式补齐。
|
|
78
|
+
- 不解压搬进 worker:产物为多帧 zstd + 帧扫描 + 记录解码 + 版本协商,`readSession` 深度耦合宿主持持层,worker 内复刻等于维护第二份产物解析实现,宿主升级即漂移。
|
|
79
|
+
- 缓存上限 200 条(`HISTORY_INPUT_LIMIT`),超出裁掉最旧(超长会话史会丢早期输入);新输入从发送到浮层可见有秒级延迟(宿主批量刷盘 200ms + 焦点对齐 + 轮询节奏),非严格实时。
|
|
80
|
+
- 缓存文件为明文 JSON(输入内容落盘 `~/.dsh/historyPrompt`,与产物本身同级敏感);单条超长文本(大段粘贴)在 host 侧截断,回填与展示均用截断后文本。
|
|
81
|
+
- 交互收敛为快捷键唤起浮层(`Alt+↑`,无 mac 系统占用,与编辑器光标移动无冲突),无常驻界面元素。
|
|
66
82
|
- 重挂载的并发窗口:attachSession 后、台账清除前进程中断,残留条目重挂载时因官方 attachSession 幂等(已在 sessionIds 中则跳过校验直写)无副作用,再次点按或「移除记录」即可收尾。台账域打开失败时已删除 / 重挂载 / forget 以 400 拒绝;删除路由走 partial 降级(其余步骤照常,提示「已移入回收站,但重挂载记录失败」),不做无台账中断——产物进回收站后中断删除只会造成状态不一致。
|
|
67
83
|
- 测试基建:trash 为进程级唯一 OS 副作用出口,经 `executor.trashPath` 注册表注入,删除全流程(trash 成败 / 台账成败 / 归档清理)已有路由层覆盖。
|
|
68
84
|
|
|
@@ -74,10 +90,10 @@ npm test
|
|
|
74
90
|
|
|
75
91
|
纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip,`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
|
|
76
92
|
|
|
77
|
-
## 开发安装(
|
|
93
|
+
## 开发安装(仓库工作副本直挂,不经 npm 发布)
|
|
78
94
|
|
|
79
95
|
```sh
|
|
80
|
-
dsh
|
|
96
|
+
node scripts/dev-link.mjs dsh-session-manager # 仓库根执行:归一 profile 依赖行 + 挂 junction
|
|
81
97
|
```
|
|
82
98
|
|
|
83
|
-
`
|
|
99
|
+
工作副本以 junction 挂进 profile,改代码保存即热重载(host 半区)或刷新页面生效(client 半区),无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// conversation.input.dock 插槽(行数据由 host /api/session-manager/inputs 聚合),
|
|
7
7
|
// 回填走宿主公共契约 inputActions.setDraft。浏览器半区经 webServer
|
|
8
8
|
// 路由('/api/session-manager/*')访问 Host。打包为单文件自包含格式,无法跨文件
|
|
9
|
-
// require;与 src/core.mjs 镜像的纯函数(
|
|
10
|
-
// projectDeletedRows
|
|
9
|
+
// require;与 src/core.mjs 镜像的纯函数(projectRows / archiveToastStep /
|
|
10
|
+
// archiveToastText / projectDeletedRows / pageArchiveRows /
|
|
11
|
+
// groupArchiveRowsByWorkspace / filterArchiveRows)修改需两处同步。
|
|
11
12
|
|
|
12
13
|
window.__ModuleLoader__.load({
|
|
13
14
|
id: '@mzzsfy/dsh-session-manager',
|
|
@@ -71,6 +72,28 @@ const CSS = [
|
|
|
71
72
|
'.sm-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
|
|
72
73
|
'.sm-empty { padding:24px 12px; text-align:center; color:var(--dsw-alias-label-caption); }',
|
|
73
74
|
'.sm-empty__hint { font:var(--dsw-font-xxs-12); margin-top:2px; }',
|
|
75
|
+
// 归档库二级视图:工具行(搜索)与工作区分组、分页展开按钮
|
|
76
|
+
'.sm-lib { display:flex; flex-direction:column; gap:8px; }',
|
|
77
|
+
'.sm-libbar { display:flex; align-items:center; gap:8px; }',
|
|
78
|
+
'.sm-libbar__search { flex:1; min-width:0; padding:4px 10px; border-radius:8px; color-scheme:light dark;',
|
|
79
|
+
' border:1px solid var(--dsw-alias-border-l3); background:transparent; color:inherit; font:var(--dsw-font-s-14); }',
|
|
80
|
+
'.sm-libbar__search:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
|
|
81
|
+
'.sm-libbar__count { flex:none; font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
|
|
82
|
+
'.sm-group { display:flex; flex-direction:column; }',
|
|
83
|
+
'.sm-group__head { display:flex; align-items:center; gap:8px; width:100%; border:0; background:transparent; cursor:pointer;',
|
|
84
|
+
' padding:8px 10px; border-radius:8px; text-align:left; color:inherit; font:var(--dsw-font-s-strong-14); }',
|
|
85
|
+
'.sm-group__head:hover { background:var(--dsw-alias-interactive-bg-hover); }',
|
|
86
|
+
'.sm-group__head:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
|
|
87
|
+
'.sm-group__chev { flex:none; color:var(--dsw-alias-label-caption); transition:transform .15s ease; }',
|
|
88
|
+
'.sm-group__head--on .sm-group__chev { transform:rotate(90deg); }',
|
|
89
|
+
'.sm-group__name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
|
90
|
+
'.sm-group__count { margin-left:auto; flex:none; font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
|
|
91
|
+
'.sm-group__body { padding-bottom:4px; }',
|
|
92
|
+
'.sm-more { display:block; width:100%; border:0; background:transparent; cursor:pointer; padding:8px;',
|
|
93
|
+
' text-align:center; border-radius:8px; font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-caption); }',
|
|
94
|
+
'.sm-more:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
|
|
95
|
+
'.sm-more:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
|
|
96
|
+
'@media (prefers-reduced-motion: reduce) { .sm-group__chev { transition:none; } }',
|
|
74
97
|
'.sm-histsw { display:inline-flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;',
|
|
75
98
|
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
|
|
76
99
|
'.sm-histsw input[type="checkbox"] { position:absolute; width:1px; height:1px; margin:-1px; opacity:0; }',
|
|
@@ -287,6 +310,66 @@ function archiveToastStep(previous, snapshot) {
|
|
|
287
310
|
return { state: { ready, ids }, added }
|
|
288
311
|
}
|
|
289
312
|
|
|
313
|
+
// 归档通知标题列举上限:超过该数只列前段并以「 等」收尾(镜像 core.mjs 同名常量)
|
|
314
|
+
const TOAST_MAX_TITLES = 3
|
|
315
|
+
|
|
316
|
+
// 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
|
|
317
|
+
// 「 等」收尾,计数恒为真实总数;标题缺失、空白或行不存在回退会话 id
|
|
318
|
+
// (镜像 core.mjs archiveToastText,修改需两处同步)
|
|
319
|
+
function archiveToastText(addedIds, rows) {
|
|
320
|
+
const added = Array.isArray(addedIds) ? addedIds : []
|
|
321
|
+
if (added.length === 0) return ''
|
|
322
|
+
const safeRows = Array.isArray(rows) ? rows : []
|
|
323
|
+
const titleById = new Map()
|
|
324
|
+
for (const row of safeRows) {
|
|
325
|
+
if (!row || row.id === undefined || row.id === null) continue
|
|
326
|
+
const trimmed = String(row.title ?? '').trim()
|
|
327
|
+
if (trimmed !== '') titleById.set(String(row.id), trimmed)
|
|
328
|
+
}
|
|
329
|
+
const names = added.map((id) => titleById.get(String(id)) || String(id))
|
|
330
|
+
if (names.length === 1) return '会话「' + names[0] + '」已归档'
|
|
331
|
+
const listed = names.slice(0, TOAST_MAX_TITLES)
|
|
332
|
+
return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 归档库主列表默认展开行数与手动展开步长(镜像 core.mjs 同名常量)
|
|
336
|
+
const ARCHIVE_PAGE_SIZE = 100
|
|
337
|
+
|
|
338
|
+
// 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理
|
|
339
|
+
// (镜像 core.mjs pageArchiveRows,修改需两处同步)
|
|
340
|
+
function pageArchiveRows(rows, visibleCount) {
|
|
341
|
+
const list = Array.isArray(rows) ? rows : []
|
|
342
|
+
const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
|
|
343
|
+
const visible = list.slice(0, count)
|
|
344
|
+
return { visible, remaining: list.length - visible.length }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序,
|
|
348
|
+
// 组间按组内最新 updatedAt 倒序(镜像 core.mjs groupArchiveRowsByWorkspace,修改需两处同步)
|
|
349
|
+
function groupArchiveRowsByWorkspace(rows) {
|
|
350
|
+
const list = Array.isArray(rows) ? rows : []
|
|
351
|
+
const byKey = new Map()
|
|
352
|
+
for (const row of list) {
|
|
353
|
+
const title = row.workspace ? String(row.workspace) : ''
|
|
354
|
+
if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
|
|
355
|
+
const group = byKey.get(title)
|
|
356
|
+
group.rows.push(row)
|
|
357
|
+
if (row.updatedAt > group.latest) group.latest = row.updatedAt
|
|
358
|
+
}
|
|
359
|
+
return [...byKey.values()]
|
|
360
|
+
.sort((left, right) => right.latest - left.latest)
|
|
361
|
+
.map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,空查询原样
|
|
365
|
+
// 返回全量(镜像 core.mjs filterArchiveRows,修改需两处同步)
|
|
366
|
+
function filterArchiveRows(rows, query) {
|
|
367
|
+
const list = Array.isArray(rows) ? rows : []
|
|
368
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
369
|
+
if (needle === '') return list
|
|
370
|
+
return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
|
|
371
|
+
}
|
|
372
|
+
|
|
290
373
|
function ArchiveRow(props) {
|
|
291
374
|
const row = props.row
|
|
292
375
|
const armed = props.armed
|
|
@@ -367,6 +450,26 @@ function SessionManagerApp(props) {
|
|
|
367
450
|
const [confirms, setConfirms] = useState({})
|
|
368
451
|
const [deleted, setDeleted] = useState([])
|
|
369
452
|
const [periodic, setPeriodic] = useState(null)
|
|
453
|
+
// 归档库视图状态:recent 为主列表固定首页,library 为工作区分组浏览 + 标题
|
|
454
|
+
// 搜索;分组默认收起,展开即见首页,组内沿用同一分页机制
|
|
455
|
+
const [view, setView] = useState('recent')
|
|
456
|
+
const [query, setQuery] = useState('')
|
|
457
|
+
const [libraryCount, setLibraryCount] = useState(ARCHIVE_PAGE_SIZE)
|
|
458
|
+
const [openGroups, setOpenGroups] = useState(() => new Set())
|
|
459
|
+
const [groupCounts, setGroupCounts] = useState({})
|
|
460
|
+
|
|
461
|
+
function toggleGroup(key) {
|
|
462
|
+
setOpenGroups((prev) => {
|
|
463
|
+
const next = new Set(prev)
|
|
464
|
+
if (next.has(key)) next.delete(key)
|
|
465
|
+
else next.add(key)
|
|
466
|
+
return next
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function expandGroup(key) {
|
|
471
|
+
setGroupCounts((prev) => ({ ...prev, [key]: (prev[key] || ARCHIVE_PAGE_SIZE) + ARCHIVE_PAGE_SIZE }))
|
|
472
|
+
}
|
|
370
473
|
|
|
371
474
|
function refreshDeleted() {
|
|
372
475
|
api(DELETED_URL)
|
|
@@ -429,34 +532,115 @@ function SessionManagerApp(props) {
|
|
|
429
532
|
.then(refreshDeleted)
|
|
430
533
|
}
|
|
431
534
|
|
|
535
|
+
const renderArchiveRow = (row) => h(ArchiveRow, {
|
|
536
|
+
key: row.id,
|
|
537
|
+
row,
|
|
538
|
+
armed: armedId === row.id,
|
|
539
|
+
busy: busyId === row.id,
|
|
540
|
+
confirm: confirms[row.id],
|
|
541
|
+
onUnarchive: () => {
|
|
542
|
+
void run(row.id, () => api(UNARCHIVE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }))
|
|
543
|
+
},
|
|
544
|
+
onDelete: () => onDelete(row),
|
|
545
|
+
onConfirm: () => onDelete(row),
|
|
546
|
+
onDisarm: () => { setArmedId(null); setConfirms({}) },
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
// 分页展开按钮:无余量不渲染(React 忽略 null 子节点);key 恒定,
|
|
550
|
+
// 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
|
|
551
|
+
const expandMore = (remaining, onMore) => remaining <= 0
|
|
552
|
+
? null
|
|
553
|
+
: h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
|
|
554
|
+
|
|
555
|
+
// 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
|
|
556
|
+
const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
|
|
557
|
+
const recentTray = h('div', { className: 'sm-tray' },
|
|
558
|
+
rows.length === 0
|
|
559
|
+
? h('div', { className: 'sm-empty' },
|
|
560
|
+
h('div', null, '还没有归档的会话'),
|
|
561
|
+
h('div', { className: 'sm-empty__hint' }, '会话归档后集中显示在这里'))
|
|
562
|
+
: [...recentPage.visible.map(renderArchiveRow),
|
|
563
|
+
recentPage.remaining > 0
|
|
564
|
+
? h('button', { key: 'more', className: 'sm-more', onClick: () => setView('library') }, '按工作区浏览')
|
|
565
|
+
: null],
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
// 归档库区域:有搜索词时命中平铺(分页),无搜索词按工作区分组(默认收起);
|
|
569
|
+
// 仅二级视图构建,recent 视图不做分组与搜索投影,防大集合重渲染放大
|
|
570
|
+
function renderLibrary() {
|
|
571
|
+
const searching = query.trim() !== ''
|
|
572
|
+
const matchedRows = filterArchiveRows(rows, query)
|
|
573
|
+
const searchPage = pageArchiveRows(matchedRows, libraryCount)
|
|
574
|
+
const tray = rows.length === 0
|
|
575
|
+
? h('div', { className: 'sm-empty' }, '还没有归档的会话')
|
|
576
|
+
: searching
|
|
577
|
+
? (matchedRows.length === 0
|
|
578
|
+
? h('div', { className: 'sm-empty' }, '没有匹配的归档会话')
|
|
579
|
+
: h('div', { className: 'sm-tray' },
|
|
580
|
+
[...searchPage.visible.map(renderArchiveRow),
|
|
581
|
+
expandMore(searchPage.remaining, () => setLibraryCount((count) => count + ARCHIVE_PAGE_SIZE))]))
|
|
582
|
+
: h('div', { className: 'sm-tray' },
|
|
583
|
+
groupArchiveRowsByWorkspace(rows).map((group) => {
|
|
584
|
+
// 未分组桶键为空串,空串本身是合法 React key,直接用桶键防与用户标题撞车
|
|
585
|
+
const key = group.workspace || ''
|
|
586
|
+
const open = openGroups.has(key)
|
|
587
|
+
const page = pageArchiveRows(group.rows, groupCounts[key] || ARCHIVE_PAGE_SIZE)
|
|
588
|
+
return h('div', { className: 'sm-group', key },
|
|
589
|
+
h('button', {
|
|
590
|
+
className: 'sm-group__head' + (open ? ' sm-group__head--on' : ''),
|
|
591
|
+
'aria-expanded': open,
|
|
592
|
+
onClick: () => toggleGroup(key),
|
|
593
|
+
},
|
|
594
|
+
h('span', { className: 'sm-group__chev', 'aria-hidden': 'true' }, '▸'),
|
|
595
|
+
h('span', { className: 'sm-group__name' }, group.workspace || '未分组'),
|
|
596
|
+
h('span', { className: 'sm-group__count' }, group.rows.length + ' 条'),
|
|
597
|
+
),
|
|
598
|
+
open
|
|
599
|
+
? h('div', { className: 'sm-group__body' },
|
|
600
|
+
page.visible.map(renderArchiveRow),
|
|
601
|
+
expandMore(page.remaining, () => expandGroup(key)))
|
|
602
|
+
: null,
|
|
603
|
+
)
|
|
604
|
+
}))
|
|
605
|
+
return h('div', { className: 'sm-lib' },
|
|
606
|
+
h('div', { className: 'sm-libbar' },
|
|
607
|
+
h('input', {
|
|
608
|
+
className: 'sm-libbar__search',
|
|
609
|
+
type: 'search',
|
|
610
|
+
placeholder: '搜索会话标题',
|
|
611
|
+
'aria-label': '搜索会话标题',
|
|
612
|
+
value: query,
|
|
613
|
+
// 搜索词变化即重置平铺分页:命中集变小后旧可见数无意义
|
|
614
|
+
onChange: (event) => { setQuery(event.target.value); setLibraryCount(ARCHIVE_PAGE_SIZE) },
|
|
615
|
+
}),
|
|
616
|
+
searching ? h('span', { className: 'sm-libbar__count' }, matchedRows.length + ' 条匹配') : null,
|
|
617
|
+
),
|
|
618
|
+
tray,
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const archiveRegion = view === 'recent' ? recentTray : renderLibrary()
|
|
623
|
+
|
|
432
624
|
return h('div', { className: 'sm-panel' },
|
|
433
|
-
|
|
434
|
-
h('
|
|
435
|
-
|
|
436
|
-
|
|
625
|
+
view === 'recent'
|
|
626
|
+
? h('div', { className: 'sm-head' },
|
|
627
|
+
h('span', { className: 'sm-head__title' }, '会话归档'),
|
|
628
|
+
rows.length > 0 ? h('span', { className: 'sm-head__count' }, rows.length + ' 条') : null,
|
|
629
|
+
rows.length > 0
|
|
630
|
+
? h('button', { className: 'sm-btn', onClick: () => setView('library') }, '按工作区浏览 ›')
|
|
631
|
+
: null,
|
|
632
|
+
)
|
|
633
|
+
: h('div', { className: 'sm-head' },
|
|
634
|
+
h('button', { className: 'sm-btn', onClick: () => setView('recent') }, '‹ 返回'),
|
|
635
|
+
h('span', { className: 'sm-head__title' }, '按工作区浏览'),
|
|
636
|
+
h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
|
|
637
|
+
),
|
|
437
638
|
h('div', { className: 'sm-head__hint' }, '恢复放回会话列表;删除移入系统回收站,可还原后重新挂载。'),
|
|
438
639
|
periodic && periodic.running === false
|
|
439
640
|
? h('div', { className: 'sm-head__hint' },
|
|
440
641
|
'周期评估未运行(' + (periodic.reason || '宿主定时服务不可用') + ');自动归档仍在新会话创建与启动时生效。')
|
|
441
642
|
: null,
|
|
442
|
-
|
|
443
|
-
rows.length === 0
|
|
444
|
-
? h('div', { className: 'sm-empty' },
|
|
445
|
-
h('div', null, '还没有归档的会话'),
|
|
446
|
-
h('div', { className: 'sm-empty__hint' }, '会话归档后集中显示在这里'))
|
|
447
|
-
: rows.map((row) => h(ArchiveRow, {
|
|
448
|
-
key: row.id,
|
|
449
|
-
row,
|
|
450
|
-
armed: armedId === row.id,
|
|
451
|
-
busy: busyId === row.id,
|
|
452
|
-
confirm: confirms[row.id],
|
|
453
|
-
onUnarchive: () => {
|
|
454
|
-
void run(row.id, () => api(UNARCHIVE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }))
|
|
455
|
-
},
|
|
456
|
-
onDelete: () => onDelete(row),
|
|
457
|
-
onConfirm: () => onDelete(row),
|
|
458
|
-
onDisarm: () => { setArmedId(null); setConfirms({}) },
|
|
459
|
-
}))),
|
|
643
|
+
archiveRegion,
|
|
460
644
|
h(DeletedSection, {
|
|
461
645
|
rows: projectDeletedRows(deleted, listState),
|
|
462
646
|
busyId,
|
|
@@ -928,12 +1112,18 @@ function HistoryDock({ session, inputActions }) {
|
|
|
928
1112
|
return () => style.remove()
|
|
929
1113
|
}, 'session-manager styles')
|
|
930
1114
|
|
|
931
|
-
//
|
|
1115
|
+
// 归档快照差分:新增驱动通知,文案携带会话标题
|
|
932
1116
|
let previous
|
|
933
1117
|
const unsubscribe = workspaces.list.subscribe(() => {
|
|
934
1118
|
const step = archiveToastStep(previous, workspaces.list.getSnapshot())
|
|
935
1119
|
previous = step.state
|
|
936
|
-
if (step.added.length > 0)
|
|
1120
|
+
if (step.added.length > 0) {
|
|
1121
|
+
// 会话行快照与面板投影同源取 byId,标题映射为通知文案行数据
|
|
1122
|
+
const listState = sessions.list.getSnapshot()
|
|
1123
|
+
const byId = (listState && listState.byId) || {}
|
|
1124
|
+
const rows = Object.keys(byId).map((id) => ({ id, title: byId[id].displayTitle }))
|
|
1125
|
+
toast(archiveToastText(step.added, rows))
|
|
1126
|
+
}
|
|
937
1127
|
})
|
|
938
1128
|
|
|
939
1129
|
ctx.slots.inject('settings.section', () =>
|
package/src/core.mjs
CHANGED
|
@@ -103,6 +103,31 @@ export function archiveToastStep(previous, snapshot) {
|
|
|
103
103
|
return { state: { ready, ids }, added }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** 归档通知标题列举上限:超过该数只列前段并以「 等」收尾。 */
|
|
107
|
+
export const TOAST_MAX_TITLES = 3
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
|
|
111
|
+
* 「 等」收尾,计数恒为真实总数。标题按去除首尾空白取值,缺失、空白或行不存在
|
|
112
|
+
* 回退会话 id,行数据非数组视为空。client.js 有镜像实现(单文件自包含无法跨文件
|
|
113
|
+
* require),修改需两处同步。
|
|
114
|
+
*/
|
|
115
|
+
export function archiveToastText(addedIds, rows) {
|
|
116
|
+
const added = Array.isArray(addedIds) ? addedIds : []
|
|
117
|
+
if (added.length === 0) return ''
|
|
118
|
+
const safeRows = Array.isArray(rows) ? rows : []
|
|
119
|
+
const titleById = new Map()
|
|
120
|
+
for (const row of safeRows) {
|
|
121
|
+
if (!row || row.id === undefined || row.id === null) continue
|
|
122
|
+
const trimmed = String(row.title ?? '').trim()
|
|
123
|
+
if (trimmed !== '') titleById.set(String(row.id), trimmed)
|
|
124
|
+
}
|
|
125
|
+
const names = added.map((id) => titleById.get(String(id)) || String(id))
|
|
126
|
+
if (names.length === 1) return '会话「' + names[0] + '」已归档'
|
|
127
|
+
const listed = names.slice(0, TOAST_MAX_TITLES)
|
|
128
|
+
return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
/** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
|
|
107
132
|
export function deleteEligibility({ archivedIds, sessionId }) {
|
|
108
133
|
return archivedIds.includes(sessionId)
|
|
@@ -175,6 +200,53 @@ export function projectDeletedRows(deleted, sessionsById) {
|
|
|
175
200
|
}))
|
|
176
201
|
}
|
|
177
202
|
|
|
203
|
+
// ── 归档库投影:主列表分页 / 工作区分组 / 标题搜索 ──
|
|
204
|
+
|
|
205
|
+
/** 归档库主列表默认展开行数与手动展开步长。 */
|
|
206
|
+
export const ARCHIVE_PAGE_SIZE = 100
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理。
|
|
210
|
+
* client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
211
|
+
*/
|
|
212
|
+
export function pageArchiveRows(rows, visibleCount) {
|
|
213
|
+
const list = Array.isArray(rows) ? rows : []
|
|
214
|
+
const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
|
|
215
|
+
const visible = list.slice(0, count)
|
|
216
|
+
return { visible, remaining: list.length - visible.length }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序
|
|
221
|
+
* (调用方输入恒为时间倒序投影),组间按组内最新 updatedAt 倒序。
|
|
222
|
+
* client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
223
|
+
*/
|
|
224
|
+
export function groupArchiveRowsByWorkspace(rows) {
|
|
225
|
+
const list = Array.isArray(rows) ? rows : []
|
|
226
|
+
const byKey = new Map()
|
|
227
|
+
for (const row of list) {
|
|
228
|
+
const title = row.workspace ? String(row.workspace) : ''
|
|
229
|
+
if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
|
|
230
|
+
const group = byKey.get(title)
|
|
231
|
+
group.rows.push(row)
|
|
232
|
+
if (row.updatedAt > group.latest) group.latest = row.updatedAt
|
|
233
|
+
}
|
|
234
|
+
return [...byKey.values()]
|
|
235
|
+
.sort((left, right) => right.latest - left.latest)
|
|
236
|
+
.map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,
|
|
241
|
+
* 空查询原样返回全量。client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
|
|
242
|
+
*/
|
|
243
|
+
export function filterArchiveRows(rows, query) {
|
|
244
|
+
const list = Array.isArray(rows) ? rows : []
|
|
245
|
+
const needle = String(query ?? '').trim().toLowerCase()
|
|
246
|
+
if (needle === '') return list
|
|
247
|
+
return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
|
|
248
|
+
}
|
|
249
|
+
|
|
178
250
|
// 历史输入回溯参数:host 聚合路由使用(client 单文件自包含不经此,展示侧另行内联)。
|
|
179
251
|
// 范围按 ←/→ 切换序排列:索引 0 为常用提示词(个人收藏),索引 1 为当前会话
|
|
180
252
|
// (浮层默认落点),→ 向更大范围,← 返回收藏
|
package/src/history-cache.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// 文件名 = 工作区路径末段(可辨认)+ 路径哈希前缀(防不同目录同名冲突)
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto'
|
|
9
|
-
import { mkdir, readFile, readdir, writeFile, rename } from 'node:fs/promises'
|
|
10
|
-
import { join } from 'node:path'
|
|
9
|
+
import { mkdir, readFile, readdir, stat, writeFile, rename } from 'node:fs/promises'
|
|
10
|
+
import { dirname, join, sep } from 'node:path'
|
|
11
11
|
|
|
12
12
|
function workspaceFile(dir, cwd) {
|
|
13
13
|
const leaf = cwd.split(/[\\/]/).filter(Boolean).pop() || 'root'
|
|
@@ -45,24 +45,57 @@ export async function writeWorkspaceCache(dir, cwd, cache) {
|
|
|
45
45
|
await rename(tmp, target)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// global 浮层轮询用指纹缓存:每 3s 轮询都全量读取并 JSON.parse 所有工作区缓存文件,
|
|
49
|
+
// 单文件 entries 可达数百条,持续打开浮层时是秒级重复大 IO+CPU。以 (mtimeMs,size)
|
|
50
|
+
// 指纹复用上次解析结果,产物未变的文件不再读盘;写入侧经临时文件改名,mtime 必变,
|
|
51
|
+
// 指纹天然失效。模块级 Map 以全路径为键(防多目录同名文件串扰),条目数有界。
|
|
52
|
+
const globalCachePollState = new Map()
|
|
53
|
+
|
|
54
|
+
export async function listWorkspaceCachesCached(dir) {
|
|
49
55
|
let names
|
|
50
56
|
try {
|
|
51
57
|
names = await readdir(dir)
|
|
52
58
|
} catch {
|
|
59
|
+
// 目录缺失/不可读:仅清本目录条目(分隔符锚定,防误清兄弟目录),条目自愈
|
|
60
|
+
for (const key of globalCachePollState.keys()) {
|
|
61
|
+
if (key.startsWith(dir + sep)) globalCachePollState.delete(key)
|
|
62
|
+
}
|
|
53
63
|
return []
|
|
54
64
|
}
|
|
65
|
+
const live = new Set()
|
|
55
66
|
const caches = await Promise.all(names
|
|
56
67
|
.filter((name) => name.endsWith('.json') && name !== PROMPTS_FILE)
|
|
57
68
|
.map(async (name) => {
|
|
69
|
+
const file = join(dir, name)
|
|
70
|
+
live.add(file)
|
|
71
|
+
let fingerprint
|
|
58
72
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
73
|
+
const info = await stat(file)
|
|
74
|
+
fingerprint = { mtimeMs: info.mtimeMs, size: info.size }
|
|
75
|
+
} catch {
|
|
76
|
+
globalCachePollState.delete(file)
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
const hit = globalCachePollState.get(file)
|
|
80
|
+
if (hit !== undefined && hit.fp.mtimeMs === fingerprint.mtimeMs && hit.fp.size === fingerprint.size) {
|
|
81
|
+
return hit.parsed
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'))
|
|
85
|
+
if (!parsed || typeof parsed.cwd !== 'string' || !Array.isArray(parsed.entries)) {
|
|
86
|
+
globalCachePollState.delete(file)
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
globalCachePollState.set(file, { fp: fingerprint, parsed })
|
|
61
90
|
return parsed
|
|
62
91
|
} catch {
|
|
92
|
+
globalCachePollState.delete(file)
|
|
63
93
|
return null
|
|
64
94
|
}
|
|
65
95
|
}))
|
|
96
|
+
for (const key of globalCachePollState.keys()) {
|
|
97
|
+
if (!live.has(key)) globalCachePollState.delete(key)
|
|
98
|
+
}
|
|
66
99
|
return caches.filter((cache) => cache && typeof cache.cwd === 'string' && Array.isArray(cache.entries))
|
|
67
100
|
}
|
|
68
101
|
|
package/src/index.js
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
selectArchiveCandidates,
|
|
43
43
|
updatedAtOf,
|
|
44
44
|
} from './core.mjs'
|
|
45
|
-
import { ensureCacheDir,
|
|
45
|
+
import { ensureCacheDir, listWorkspaceCachesCached, readPrompts, readWorkspaceCache, writePrompts, writeWorkspaceCache } from './history-cache.mjs'
|
|
46
46
|
import { trashPath } from './trash.mjs'
|
|
47
47
|
|
|
48
48
|
export const name = 'dsh-session-manager'
|
|
@@ -250,6 +250,9 @@ export function apply(ctx, config) {
|
|
|
250
250
|
for (const recordItem of records) {
|
|
251
251
|
const header = recordItem.header
|
|
252
252
|
if (cwd !== undefined && header.cwd !== cwd) continue
|
|
253
|
+
// subagent 内部会话不属主列表(官方 sessionVisible 以 origin 排除),归档集合
|
|
254
|
+
// 仅服务用户会话:误归档只产出不可辨认的面板行与无意义通知
|
|
255
|
+
if (header.origin === 'subagent') continue
|
|
253
256
|
// 预筛:createdAt 未超期必不超期(见上),跳过产物 IO
|
|
254
257
|
if (header.createdAt >= cutoff) continue
|
|
255
258
|
// 已归档记录永不是候选,集合随历史增长,提前跳过省产物 IO
|
|
@@ -282,6 +285,7 @@ export function apply(ctx, config) {
|
|
|
282
285
|
for (const id of selectArchiveCandidates({ records: candidates, nowMs, thresholdDays: days })) {
|
|
283
286
|
try {
|
|
284
287
|
await registry.archiveSession(id)
|
|
288
|
+
ctx.logger && ctx.logger.info && ctx.logger.info('session-manager 已归档会话: ' + id)
|
|
285
289
|
} catch (error) {
|
|
286
290
|
// 单个归档失败不中断整轮,剩余候选继续;失败者由下一次触发重试
|
|
287
291
|
ctx.logger && ctx.logger.warn('session-manager 归档 ' + id + ' 失败: ' + String(error))
|
|
@@ -601,7 +605,8 @@ export function apply(ctx, config) {
|
|
|
601
605
|
return { inputs: mine, aligned: cached !== null }
|
|
602
606
|
}
|
|
603
607
|
if (scope === 'global') {
|
|
604
|
-
|
|
608
|
+
// 浮层轮询路径:stat 指纹缓存,产物未变的文件不再重复读盘
|
|
609
|
+
const caches = await listWorkspaceCachesCached(cacheDir)
|
|
605
610
|
for (const cache of caches) alignWorkspace(cache.cwd)
|
|
606
611
|
return { inputs: aggregateInputs(caches.flatMap((cache) => cache.entries), aggregateOptions), aligned: true }
|
|
607
612
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
|
|
2
|
+
// 跨 bundle 同名即整脚本 SyntaxError 拒载。本包 client.js 以单条 __ModuleLoader__.load
|
|
3
|
+
// 语句承载全部代码(声明均在 factory 作用域内),本守卫锁定"顶层零词法声明"不变量,
|
|
4
|
+
// 防未来声明误置顶层。vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import vm from 'node:vm'
|
|
11
|
+
|
|
12
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
|
|
13
|
+
const DECLARATION_NAMES = [
|
|
14
|
+
...new Set(
|
|
15
|
+
[...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
16
|
+
),
|
|
17
|
+
]
|
|
18
|
+
const BUNDLE_ID = '@mzzsfy/dsh-session-manager'
|
|
19
|
+
|
|
20
|
+
test('Given 同 context 已有外部 CSS 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
|
|
21
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
22
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
23
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
24
|
+
assert.equal(vm.runInContext('CSS', ctx), 1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('Given client.js 已整源求值, When 同 context 再声明外部 CSS, Then 双源共存不拒载', () => {
|
|
28
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
29
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
30
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
|
|
34
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
35
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
36
|
+
const leaked = DECLARATION_NAMES.filter((name) => {
|
|
37
|
+
try {
|
|
38
|
+
vm.runInContext(`const ${name} = null`, ctx)
|
|
39
|
+
return false
|
|
40
|
+
} catch {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
assert.deepEqual(leaked, [])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
|
|
48
|
+
const registrations = []
|
|
49
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
|
|
50
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
51
|
+
assert.equal(registrations.length, 1)
|
|
52
|
+
assert.equal(registrations[0].id, BUNDLE_ID)
|
|
53
|
+
assert.equal(typeof registrations[0].factory, 'function')
|
|
54
|
+
})
|
|
@@ -43,9 +43,24 @@ function loadClient() {
|
|
|
43
43
|
getSnapshot: () => snapshot,
|
|
44
44
|
},
|
|
45
45
|
}
|
|
46
|
+
// 会话行模型:与官方 sessions.list 快照同构(ids + byId,行标题为 displayTitle),
|
|
47
|
+
// 行数据可由用例覆写
|
|
48
|
+
let sessionSnapshot = {
|
|
49
|
+
ids: ['c', 'd'],
|
|
50
|
+
byId: {
|
|
51
|
+
c: { id: 'c', displayTitle: 'c 标题' },
|
|
52
|
+
d: { id: 'd', displayTitle: 'd 标题' },
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
const sessions = {
|
|
56
|
+
list: {
|
|
57
|
+
subscribe: () => () => {},
|
|
58
|
+
getSnapshot: () => sessionSnapshot,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
46
61
|
const effects = []
|
|
47
62
|
const ctx = {
|
|
48
|
-
get: (name) => (name === 'workspaces' ? workspaces : {}),
|
|
63
|
+
get: (name) => (name === 'workspaces' ? workspaces : name === 'sessions' ? sessions : {}),
|
|
49
64
|
effect: (fn, tag) => effects.push(tag),
|
|
50
65
|
slots: { inject: () => {}, register: () => {} },
|
|
51
66
|
}
|
|
@@ -54,6 +69,7 @@ function loadClient() {
|
|
|
54
69
|
shown,
|
|
55
70
|
required,
|
|
56
71
|
emit(next) { snapshot = next; if (listener !== null) listener() },
|
|
72
|
+
setSessions(next) { sessionSnapshot = next },
|
|
57
73
|
effects,
|
|
58
74
|
}
|
|
59
75
|
}
|
|
@@ -74,7 +90,7 @@ test('apply:归档差分经 toast 出口,连续 ready 才计新增', () => {
|
|
|
74
90
|
client.emit(READY(['a', 'b']))
|
|
75
91
|
assert.deepEqual(client.shown, [], '首帧基线(存量归档)不通知')
|
|
76
92
|
client.emit(READY(['a', 'b', 'c', 'd']))
|
|
77
|
-
assert.deepEqual(client.shown.map((call) => call.text), ['有 2
|
|
93
|
+
assert.deepEqual(client.shown.map((call) => call.text), ['有 2 个会话已归档:c 标题、d 标题'])
|
|
78
94
|
assert.deepEqual(client.shown.map((call) => call.opts), [undefined])
|
|
79
95
|
client.emit(READY(['a', 'b', 'c', 'd']))
|
|
80
96
|
assert.equal(client.shown.length, 1, '无新增不重复通知')
|
|
@@ -83,6 +99,19 @@ test('apply:归档差分经 toast 出口,连续 ready 才计新增', () => {
|
|
|
83
99
|
}
|
|
84
100
|
})
|
|
85
101
|
|
|
102
|
+
test('apply:行数据缺标题时通知文案回退会话 id,单个报标题', () => {
|
|
103
|
+
mock.timers.enable({ apis: ['setTimeout'] })
|
|
104
|
+
try {
|
|
105
|
+
const client = loadClient()
|
|
106
|
+
client.setSessions({ ids: ['z'], byId: { z: { id: 'z' } } })
|
|
107
|
+
client.emit(READY(['a']))
|
|
108
|
+
client.emit(READY(['a', 'z']))
|
|
109
|
+
assert.deepEqual(client.shown.map((call) => call.text), ['会话「z」已归档'])
|
|
110
|
+
} finally {
|
|
111
|
+
mock.timers.reset()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
86
115
|
test('apply:effect 挂样式与差分退订两个副作用', () => {
|
|
87
116
|
const client = loadClient()
|
|
88
117
|
assert.deepEqual(client.effects, ['session-manager styles', 'session-manager archived diff'])
|
package/test/core.test.mjs
CHANGED
|
@@ -5,18 +5,24 @@ import test from 'node:test'
|
|
|
5
5
|
import assert from 'node:assert/strict'
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
ARCHIVE_PAGE_SIZE,
|
|
8
9
|
DAY_MS,
|
|
9
10
|
DEFAULT_AUTO_ARCHIVE_DAYS,
|
|
10
11
|
DELETE_MESSAGES,
|
|
12
|
+
TOAST_MAX_TITLES,
|
|
11
13
|
aggregateDeleteOutcome,
|
|
12
14
|
aggregateInputs,
|
|
13
15
|
archiveToastStep,
|
|
16
|
+
archiveToastText,
|
|
14
17
|
artifactLooksBlank,
|
|
15
18
|
deleteEligibility,
|
|
16
19
|
diffArchived,
|
|
17
20
|
extractUserInputs,
|
|
21
|
+
filterArchiveRows,
|
|
22
|
+
groupArchiveRowsByWorkspace,
|
|
18
23
|
isSessionRunning,
|
|
19
24
|
mergeDeletedEntry,
|
|
25
|
+
pageArchiveRows,
|
|
20
26
|
projectArchiveRows,
|
|
21
27
|
projectDeletedRows,
|
|
22
28
|
removeDeletedEntry,
|
|
@@ -168,6 +174,95 @@ test('workspaceTitleForSession 直接边界:title 与 path 皆缺为 null,title
|
|
|
168
174
|
assert.equal(workspaceTitleForSession({ workspaces: [{ path: 'C:\\w\\a', sessionIds: ['a'] }], sessionId: 'a', cwd: undefined }), 'a')
|
|
169
175
|
})
|
|
170
176
|
|
|
177
|
+
// ── 归档库:主列表分页 / 工作区分组 / 标题搜索 ──
|
|
178
|
+
|
|
179
|
+
test('归档库分页:页粒度常量', () => {
|
|
180
|
+
assert.equal(ARCHIVE_PAGE_SIZE, 100)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('归档库分页:默认只取前 100 条,剩余数准确', () => {
|
|
184
|
+
const rows = Array.from({ length: 250 }, (_, i) => ({ id: 's' + i, title: 'T' + i, updatedAt: 1000 - i, workspace: null }))
|
|
185
|
+
const page = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
|
|
186
|
+
assert.equal(page.visible.length, 100)
|
|
187
|
+
assert.equal(page.remaining, 150)
|
|
188
|
+
assert.equal(page.visible[0].id, 's0')
|
|
189
|
+
assert.equal(page.visible[99].id, 's99')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('归档库分页:增量展开与全量边界', () => {
|
|
193
|
+
const rows = Array.from({ length: 250 }, (_, i) => ({ id: 's' + i, title: 'T' + i, updatedAt: i, workspace: null }))
|
|
194
|
+
assert.equal(pageArchiveRows(rows, 200).visible.length, 200)
|
|
195
|
+
assert.equal(pageArchiveRows(rows, 200).remaining, 50)
|
|
196
|
+
assert.deepEqual(pageArchiveRows(rows, rows.length), { visible: rows, remaining: 0 })
|
|
197
|
+
assert.equal(pageArchiveRows(rows, rows.length + 10).remaining, 0)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('归档库分页:空列表与非有限可见数防御', () => {
|
|
201
|
+
assert.deepEqual(pageArchiveRows([], ARCHIVE_PAGE_SIZE), { visible: [], remaining: 0 })
|
|
202
|
+
const single = [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }]
|
|
203
|
+
assert.deepEqual(pageArchiveRows(single, Number.NaN).visible, [])
|
|
204
|
+
assert.equal(pageArchiveRows(single, Number.NaN).remaining, 1)
|
|
205
|
+
assert.equal(pageArchiveRows(single, -5).remaining, 1)
|
|
206
|
+
assert.deepEqual(pageArchiveRows(single, Infinity).visible, [])
|
|
207
|
+
assert.equal(pageArchiveRows(single, Infinity).remaining, 1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('归档库分组:按工作区标题分桶,组内保持输入序,组间按最新活跃倒序', () => {
|
|
211
|
+
const rows = [
|
|
212
|
+
{ id: 'a', title: 'A', updatedAt: 400, workspace: 'alpha' },
|
|
213
|
+
{ id: 'b', title: 'B', updatedAt: 300, workspace: 'beta' },
|
|
214
|
+
{ id: 'c', title: 'C', updatedAt: 200, workspace: 'alpha' },
|
|
215
|
+
]
|
|
216
|
+
assert.deepEqual(groupArchiveRowsByWorkspace(rows), [
|
|
217
|
+
{ workspace: 'alpha', rows: [rows[0], rows[2]] },
|
|
218
|
+
{ workspace: 'beta', rows: [rows[1]] },
|
|
219
|
+
])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('归档库分组:workspace 假值并桶为未分组(null)', () => {
|
|
223
|
+
const rows = [
|
|
224
|
+
{ id: 'u1', title: 'U1', updatedAt: 100, workspace: null },
|
|
225
|
+
{ id: 'g', title: 'G', updatedAt: 350, workspace: 'gamma' },
|
|
226
|
+
{ id: 'u2', title: 'U2', updatedAt: 500, workspace: '' },
|
|
227
|
+
{ id: 'u3', title: 'U3', updatedAt: 50, workspace: null },
|
|
228
|
+
]
|
|
229
|
+
assert.deepEqual(groupArchiveRowsByWorkspace(rows), [
|
|
230
|
+
{ workspace: null, rows: [rows[0], rows[2], rows[3]] },
|
|
231
|
+
{ workspace: 'gamma', rows: [rows[1]] },
|
|
232
|
+
])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('归档库分组:空输入与非数组防御', () => {
|
|
236
|
+
assert.deepEqual(groupArchiveRowsByWorkspace([]), [])
|
|
237
|
+
assert.deepEqual(groupArchiveRowsByWorkspace(undefined), [])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('归档库搜索:标题不区分大小写子串匹配,空查询返回全量', () => {
|
|
241
|
+
const rows = [
|
|
242
|
+
{ id: 'a', title: '修复登录页', updatedAt: 3, workspace: null },
|
|
243
|
+
{ id: 'b', title: 'Refactor API', updatedAt: 2, workspace: null },
|
|
244
|
+
{ id: 'c', title: '登录超时排查', updatedAt: 1, workspace: null },
|
|
245
|
+
]
|
|
246
|
+
assert.deepEqual(filterArchiveRows(rows, '登录'), [rows[0], rows[2]])
|
|
247
|
+
assert.deepEqual(filterArchiveRows(rows, 'REFACT'), [rows[1]])
|
|
248
|
+
assert.deepEqual(filterArchiveRows(rows, '不存在'), [])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('归档库搜索:空查询与空白查询原样返回全量,非数组防御', () => {
|
|
252
|
+
const rows = [{ id: 'a', title: 'A', updatedAt: 1, workspace: null }]
|
|
253
|
+
assert.deepEqual(filterArchiveRows(rows, ''), rows)
|
|
254
|
+
assert.deepEqual(filterArchiveRows(rows, ' '), rows)
|
|
255
|
+
assert.deepEqual(filterArchiveRows(undefined, 'A'), [])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('归档库搜索:标题缺失经投影回退会话 id 后可被命中(与投影组合)', () => {
|
|
259
|
+
const projected = projectArchiveRows({
|
|
260
|
+
rows: [{ id: 'abc-123', title: '', updatedAt: 1 }],
|
|
261
|
+
archivedIds: ['abc-123'],
|
|
262
|
+
})
|
|
263
|
+
assert.deepEqual(filterArchiveRows(projected, 'ABC-'), projected)
|
|
264
|
+
})
|
|
265
|
+
|
|
171
266
|
test('归档集合差分只报新增,首帧基线不提示', () => {
|
|
172
267
|
assert.deepEqual(diffArchived(undefined, ['x', 'y']), [])
|
|
173
268
|
assert.deepEqual(diffArchived(['x'], ['x', 'y', 'z']), ['y', 'z'])
|
|
@@ -215,6 +310,51 @@ test('Toast 差分:ready→pending→ready 重连序列不误报存量', () => {
|
|
|
215
310
|
assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] }).added, ['c'])
|
|
216
311
|
})
|
|
217
312
|
|
|
313
|
+
test('归档通知文案:截断阈值常量', () => {
|
|
314
|
+
assert.equal(TOAST_MAX_TITLES, 3)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('归档通知文案:单个会话报标题', () => {
|
|
318
|
+
assert.equal(archiveToastText(['a'], [{ id: 'a', title: '修复登录页' }]), '会话「修复登录页」已归档')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('归档通知文案:多个会话列举标题,计数为真实总数', () => {
|
|
322
|
+
const rows = [
|
|
323
|
+
{ id: 'a', title: 'A' },
|
|
324
|
+
{ id: 'b', title: 'B' },
|
|
325
|
+
]
|
|
326
|
+
assert.equal(archiveToastText(['a', 'b'], rows), '有 2 个会话已归档:A、B')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('归档通知文案:恰好等于展示阈值不截断', () => {
|
|
330
|
+
const rows = [
|
|
331
|
+
{ id: 'a', title: 'A' },
|
|
332
|
+
{ id: 'b', title: 'B' },
|
|
333
|
+
{ id: 'c', title: 'C' },
|
|
334
|
+
]
|
|
335
|
+
assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:A、B、C')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('归档通知文案:超展示阈值只列前段并以「 等」收尾,计数仍为真实总数', () => {
|
|
339
|
+
const rows = [
|
|
340
|
+
{ id: 'a', title: 'A' },
|
|
341
|
+
{ id: 'b', title: 'B' },
|
|
342
|
+
{ id: 'c', title: 'C' },
|
|
343
|
+
{ id: 'd', title: 'D' },
|
|
344
|
+
]
|
|
345
|
+
assert.equal(archiveToastText(['a', 'b', 'c', 'd'], rows), '有 4 个会话已归档:A、B、C 等')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('归档通知文案:标题缺失、空白与行缺失回退会话 id,标题按去除首尾空白取值', () => {
|
|
349
|
+
const rows = [{ id: 'a' }, { id: 'b', title: ' ' }, { id: 'x', title: 'X' }]
|
|
350
|
+
assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:a、b、c')
|
|
351
|
+
assert.equal(archiveToastText(['t'], [{ id: 't', title: ' T ' }]), '会话「T」已归档')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('归档通知文案:行数据非数组按空处理,标题全部回退会话 id', () => {
|
|
355
|
+
assert.equal(archiveToastText(['a', 'b'], undefined), '有 2 个会话已归档:a、b')
|
|
356
|
+
})
|
|
357
|
+
|
|
218
358
|
test('非归档会话拒绝删除', () => {
|
|
219
359
|
assert.equal(deleteEligibility({ archivedIds: ['a'], sessionId: 'a' }).ok, true)
|
|
220
360
|
const denied = deleteEligibility({ archivedIds: ['a'], sessionId: 'b' })
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// global 浮层轮询指纹缓存 BDD:stat 指纹命中不重复读盘,产物变更即失效,
|
|
2
|
+
// 删除/损坏文件与目录缺失不残留缓存条目。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { mkdtemp, writeFile, utimes, rm, readdir, mkdir } from 'node:fs/promises'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const { listWorkspaceCachesCached } = await import('../src/history-cache.mjs')
|
|
11
|
+
|
|
12
|
+
async function makeDir(t) {
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'sm-fp-cache-'))
|
|
14
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
15
|
+
return dir
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function writeCache(dir, name, cwd, entries, mtime) {
|
|
19
|
+
const file = join(dir, name)
|
|
20
|
+
await writeFile(file, JSON.stringify({ cwd, entries }), 'utf8')
|
|
21
|
+
if (mtime !== undefined) await utimes(file, mtime, mtime)
|
|
22
|
+
return file
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('指纹命中:产物未变时复用解析结果,内容对象同引用', async (t) => {
|
|
26
|
+
const dir = await makeDir(t)
|
|
27
|
+
await writeCache(dir, 'a-1.json', 'C:\\a', [{ sid: 's1', text: 'x' }])
|
|
28
|
+
const first = await listWorkspaceCachesCached(dir)
|
|
29
|
+
assert.equal(first.length, 1)
|
|
30
|
+
assert.equal(first[0].cwd, 'C:\\a')
|
|
31
|
+
// When 再次读取(文件未动)
|
|
32
|
+
const second = await listWorkspaceCachesCached(dir)
|
|
33
|
+
// Then 指纹命中,返回缓存中的同一解析对象
|
|
34
|
+
assert.equal(second[0], first[0])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('指纹失效:文件内容变更后重读,新内容可见', async (t) => {
|
|
38
|
+
const dir = await makeDir(t)
|
|
39
|
+
const stamp = new Date()
|
|
40
|
+
const file = await writeCache(dir, 'b-1.json', 'C:\\b', [{ sid: 's1', text: 'old' }], stamp)
|
|
41
|
+
const first = await listWorkspaceCachesCached(dir)
|
|
42
|
+
assert.equal(first[0].entries[0].text, 'old')
|
|
43
|
+
// When 追加条目并推进 mtime(写入侧经临时文件改名,mtime 必变)
|
|
44
|
+
await writeFile(file, JSON.stringify({ cwd: 'C:\\b', entries: [{ sid: 's1', text: 'old' }, { sid: 's2', text: 'new' }] }), 'utf8')
|
|
45
|
+
const later = new Date(stamp.getTime() + 2000)
|
|
46
|
+
await utimes(file, later, later)
|
|
47
|
+
const second = await listWorkspaceCachesCached(dir)
|
|
48
|
+
// Then 新内容可见
|
|
49
|
+
assert.equal(second[0].entries.length, 2)
|
|
50
|
+
assert.equal(second[0].entries[1].text, 'new')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('卫生:文件删除与目录缺失清空对应缓存', async (t) => {
|
|
54
|
+
const dir = await makeDir(t)
|
|
55
|
+
const file = await writeCache(dir, 'c-1.json', 'C:\\c', [])
|
|
56
|
+
await listWorkspaceCachesCached(dir)
|
|
57
|
+
// When 删除文件后再读
|
|
58
|
+
await rm(file)
|
|
59
|
+
const afterRemove = await listWorkspaceCachesCached(dir)
|
|
60
|
+
// Then 结果为空(目录仍在,缓存条目清除)
|
|
61
|
+
assert.deepEqual(afterRemove, [])
|
|
62
|
+
// When 目录整个消失(缓存目录被手工清理)
|
|
63
|
+
await rm(dir, { recursive: true, force: true })
|
|
64
|
+
const afterDirGone = await listWorkspaceCachesCached(dir)
|
|
65
|
+
// Then 返回空数组不抛
|
|
66
|
+
assert.deepEqual(afterDirGone, [])
|
|
67
|
+
// 目录恢复后缓存可重建
|
|
68
|
+
await mkdir(dir, { recursive: true })
|
|
69
|
+
assert.deepEqual(await readdir(dir), [])
|
|
70
|
+
await writeCache(dir, 'd-1.json', 'C:\\d', [{ sid: 's9', text: 'fresh' }])
|
|
71
|
+
const rebuilt = await listWorkspaceCachesCached(dir)
|
|
72
|
+
assert.equal(rebuilt.length, 1)
|
|
73
|
+
assert.equal(rebuilt[0].entries[0].text, 'fresh')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('过滤:损坏文件与 prompts.json 不进结果', async (t) => {
|
|
77
|
+
const dir = await makeDir(t)
|
|
78
|
+
await writeFile(join(dir, 'broken.json'), '{not json', 'utf8')
|
|
79
|
+
await writeFile(join(dir, 'prompts.json'), JSON.stringify({ items: [] }), 'utf8')
|
|
80
|
+
await writeCache(dir, 'ok-1.json', 'C:\\ok', [])
|
|
81
|
+
const caches = await listWorkspaceCachesCached(dir)
|
|
82
|
+
assert.equal(caches.length, 1)
|
|
83
|
+
assert.equal(caches[0].cwd, 'C:\\ok')
|
|
84
|
+
})
|
package/test/index.test.mjs
CHANGED
|
@@ -168,7 +168,7 @@ function makeCtx({
|
|
|
168
168
|
get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
|
|
169
169
|
on: (event, handler) => { eventHandlers[event] = handler },
|
|
170
170
|
// 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
|
|
171
|
-
logger: { warns: [], warn(message) { this.warns.push(message) }, info() {} },
|
|
171
|
+
logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
|
|
172
172
|
}
|
|
173
173
|
const ctx = new Proxy(base, {
|
|
174
174
|
get(target, prop) {
|
|
@@ -554,7 +554,7 @@ test('自动归档评估:产物不可读的会话不参与归档(防删除后复
|
|
|
554
554
|
const sessionPersistence = {
|
|
555
555
|
locate: (header) => header.id === 's1' ? { path: alivePath } : { path: path.join(dir, 'gone.jsonl') },
|
|
556
556
|
}
|
|
557
|
-
const { eventHandlers, registry } = makeCtx({
|
|
557
|
+
const { eventHandlers, registry, logger } = makeCtx({
|
|
558
558
|
archivedIds: [],
|
|
559
559
|
headers,
|
|
560
560
|
agents: new Map(),
|
|
@@ -564,6 +564,9 @@ test('自动归档评估:产物不可读的会话不参与归档(防删除后复
|
|
|
564
564
|
await waitFor(() => registry.archiveCalls.length > 0)
|
|
565
565
|
// s2 产物缺失必须被守卫跳过:否则已删除会话会被重新归档而在面板复活
|
|
566
566
|
assert.deepEqual(registry.archiveCalls, ['s1'])
|
|
567
|
+
// 归档成功须落可追溯日志(含会话 id)
|
|
568
|
+
await waitFor(() => logger.infos.length > 0)
|
|
569
|
+
assert.ok(logger.infos.some((message) => message.includes('s1')), '归档成功应落 info 日志')
|
|
567
570
|
})
|
|
568
571
|
|
|
569
572
|
test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档', skipMissingDeps, async () => {
|
|
@@ -597,6 +600,38 @@ test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档'
|
|
|
597
600
|
}
|
|
598
601
|
})
|
|
599
602
|
|
|
603
|
+
test('自动归档评估:subagent 内部会话不参与归档(官方主列表不收录 subagent)', skipMissingDeps, async () => {
|
|
604
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'sm-eval-subagent-'))
|
|
605
|
+
try {
|
|
606
|
+
const plainPath = path.join(dir, 'plain.jsonl')
|
|
607
|
+
const childPath = path.join(dir, 'child.jsonl')
|
|
608
|
+
await writeFile(plainPath, '{"header":1}\n{"event":0}\n')
|
|
609
|
+
await writeFile(childPath, '{"header":1}\n{"event":0}\n')
|
|
610
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
611
|
+
const stale = Date.now() - 30 * DAY_MS
|
|
612
|
+
await utimes(plainPath, stale / 1000, stale / 1000)
|
|
613
|
+
await utimes(childPath, stale / 1000, stale / 1000)
|
|
614
|
+
const cwd = 'C:\\x'
|
|
615
|
+
const headers = [
|
|
616
|
+
{ id: 'plain', cwd, createdAt: stale },
|
|
617
|
+
{ id: 'child', cwd, createdAt: stale, origin: 'subagent', parentSession: 'parent' },
|
|
618
|
+
]
|
|
619
|
+
const { eventHandlers, registry } = makeCtx({
|
|
620
|
+
archivedIds: [],
|
|
621
|
+
headers,
|
|
622
|
+
agents: new Map(),
|
|
623
|
+
sessionPersistence: {
|
|
624
|
+
locate: (header) => ({ path: header.id === 'plain' ? plainPath : childPath }),
|
|
625
|
+
},
|
|
626
|
+
})
|
|
627
|
+
eventHandlers['session/created']({ header: { id: 'trigger', cwd } })
|
|
628
|
+
await waitFor(() => registry.archiveCalls.length > 0)
|
|
629
|
+
assert.deepEqual(registry.archiveCalls, ['plain'], 'subagent 会话永不入选归档')
|
|
630
|
+
} finally {
|
|
631
|
+
await rm(dir, { recursive: true, force: true })
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
|
|
600
635
|
test('启动评估:settings 就绪后全量归档超期会话,不限于单工作区', skipMissingDeps, async () => {
|
|
601
636
|
const dir = await mkdtemp(path.join(tmpdir(), 'sm-startup-'))
|
|
602
637
|
try {
|
package/test/parity.test.mjs
CHANGED
|
@@ -9,7 +9,12 @@ import test from 'node:test'
|
|
|
9
9
|
import assert from 'node:assert/strict'
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
ARCHIVE_PAGE_SIZE,
|
|
12
13
|
archiveToastStep as coreArchiveToastStep,
|
|
14
|
+
archiveToastText as coreArchiveToastText,
|
|
15
|
+
filterArchiveRows,
|
|
16
|
+
groupArchiveRowsByWorkspace,
|
|
17
|
+
pageArchiveRows,
|
|
13
18
|
projectArchiveRows,
|
|
14
19
|
projectDeletedRows,
|
|
15
20
|
} from '../src/core.mjs'
|
|
@@ -27,7 +32,7 @@ assert.ok(MIRROR_START >= 0 && MIRROR_END > MIRROR_START, 'client.js 镜像函
|
|
|
27
32
|
|
|
28
33
|
const mirror = new Function(
|
|
29
34
|
CLIENT_SRC.slice(MIRROR_START, MIRROR_END)
|
|
30
|
-
+ '; return { projectRows: projectRows, projectDeletedRows: projectDeletedRows, archiveToastStep: archiveToastStep }',
|
|
35
|
+
+ '; return { projectRows: projectRows, projectDeletedRows: projectDeletedRows, archiveToastStep: archiveToastStep, archiveToastText: archiveToastText, pageArchiveRows: pageArchiveRows, groupArchiveRowsByWorkspace: groupArchiveRowsByWorkspace, filterArchiveRows: filterArchiveRows }',
|
|
31
36
|
)()
|
|
32
37
|
|
|
33
38
|
// client 侧输入形态 byId 字典,core 侧 rows 数组:按 title/cwd 约定构造等价输入
|
|
@@ -144,6 +149,25 @@ test('parity Toast 差分:订阅即 ready(无 pending 帧)首帧守卫', () => {
|
|
|
144
149
|
])
|
|
145
150
|
})
|
|
146
151
|
|
|
152
|
+
function assertToastTextParity(addedIds, rows) {
|
|
153
|
+
assert.equal(mirror.archiveToastText(addedIds, rows), coreArchiveToastText(addedIds, rows),
|
|
154
|
+
'归档通知文案不一致: ' + JSON.stringify(addedIds))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test('parity 归档通知文案:单标题/多标题/截断/回退/非数组行同输入同输出', () => {
|
|
158
|
+
const rows = [
|
|
159
|
+
{ id: 'a', title: 'A' },
|
|
160
|
+
{ id: 'b', title: ' B ' },
|
|
161
|
+
{ id: 'c' },
|
|
162
|
+
{ id: 'd', title: ' ' },
|
|
163
|
+
]
|
|
164
|
+
assertToastTextParity(['a'], rows)
|
|
165
|
+
assertToastTextParity(['a', 'b'], rows)
|
|
166
|
+
assertToastTextParity(['a', 'b', 'c', 'd'], rows)
|
|
167
|
+
assertToastTextParity(['missing'], rows)
|
|
168
|
+
assertToastTextParity(['a', 'b'], undefined)
|
|
169
|
+
})
|
|
170
|
+
|
|
147
171
|
test('parity Toast 差分:大集合增量性能形态一致性(n=5000)', () => {
|
|
148
172
|
const big = Array.from({ length: 5000 }, (_, i) => 'id-' + i)
|
|
149
173
|
assertToastStepParity([
|
|
@@ -152,6 +176,41 @@ test('parity Toast 差分:大集合增量性能形态一致性(n=5000)', () => {
|
|
|
152
176
|
])
|
|
153
177
|
})
|
|
154
178
|
|
|
179
|
+
// ── 归档库投影 parity:分页 / 分组 / 搜索 ──
|
|
180
|
+
|
|
181
|
+
const PAGE_ROWS = Array.from({ length: 250 }, (_, i) => ({
|
|
182
|
+
id: 's' + i,
|
|
183
|
+
title: '标题' + i,
|
|
184
|
+
updatedAt: 1000 - i,
|
|
185
|
+
workspace: i % 2 ? 'alpha' : null,
|
|
186
|
+
}))
|
|
187
|
+
|
|
188
|
+
test('parity 归档库分页:页界与剩余量同输入同输出', () => {
|
|
189
|
+
for (const count of [ARCHIVE_PAGE_SIZE, 200, 250, 300, 0, Number.NaN, -1]) {
|
|
190
|
+
assert.deepEqual(mirror.pageArchiveRows(PAGE_ROWS, count), pageArchiveRows(PAGE_ROWS, count))
|
|
191
|
+
}
|
|
192
|
+
assert.deepEqual(mirror.pageArchiveRows(undefined, ARCHIVE_PAGE_SIZE), pageArchiveRows(undefined, ARCHIVE_PAGE_SIZE))
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('parity 归档库分组:分桶/未分组并桶/组间倒序同输入同输出', () => {
|
|
196
|
+
assert.deepEqual(mirror.groupArchiveRowsByWorkspace(PAGE_ROWS), groupArchiveRowsByWorkspace(PAGE_ROWS))
|
|
197
|
+
assert.deepEqual(mirror.groupArchiveRowsByWorkspace([]), groupArchiveRowsByWorkspace([]))
|
|
198
|
+
assert.deepEqual(mirror.groupArchiveRowsByWorkspace(undefined), groupArchiveRowsByWorkspace(undefined))
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('parity 归档库搜索:子串/空白/空查询同输入同输出', () => {
|
|
202
|
+
for (const query of ['标题1', '标题250', ' ', '', '不存在']) {
|
|
203
|
+
assert.deepEqual(mirror.filterArchiveRows(PAGE_ROWS, query), filterArchiveRows(PAGE_ROWS, query))
|
|
204
|
+
}
|
|
205
|
+
assert.deepEqual(mirror.filterArchiveRows(undefined, '标题'), filterArchiveRows(undefined, '标题'))
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('源码契约:client 分页粒度常量与 core ARCHIVE_PAGE_SIZE 同值', () => {
|
|
209
|
+
// 词边界锚定:纯 includes 对 1000 等以 100 为前缀的值恒真,漂移会静默通过
|
|
210
|
+
const guard = new RegExp('const ARCHIVE_PAGE_SIZE = ' + ARCHIVE_PAGE_SIZE + '\\b')
|
|
211
|
+
assert.ok(guard.test(CLIENT_SRC), 'client 分页粒度常量与 core 漂移')
|
|
212
|
+
})
|
|
213
|
+
|
|
155
214
|
test('源码契约:client fetchInputs 必须解包 host 响应信封 { inputs }', () => {
|
|
156
215
|
// host 与 client 对信封各自测试自洽时,信封形状漂移只会以运行时 TypeError 暴露;
|
|
157
216
|
// 此守卫锁定解包点存在,防消费侧把信封对象当数组使用
|