@mzzsfy/dsh-session-manager 0.4.0 → 0.6.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 CHANGED
@@ -8,8 +8,9 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
8
8
  - 常用提示词收藏:`prompts` 以外的范围,历史行鼠标悬停时行尾出现收藏星标(已收藏实星、未收藏空心),点击即收藏/取消(经 `POST /api/session-manager/prompts/toggle`),toast 反馈并同步星标;`prompts` 范围右上角「编辑」按钮进入编辑态:行内文本框直接改(Enter/失焦保存,实现为移除旧文本 + 收藏新文本)、行尾 `×` 删除、底部输入框 Enter 新增,「完成」退出;上限 `HISTORY_PROMPTS_MAX` 条,超出裁掉最旧;收藏与自动对齐的历史缓存互不覆写(`listWorkspaceCaches` 跳过 prompts.json);client 与 core 的 `HISTORY_SCOPES` 镜像由 parity 测试锁定。
9
9
  - 历史浮层启停开关:低频功能,归档面板末行小尺寸开关(规约 switch 形态 `sm-histsw`,状态选择器锚定 `input[type=checkbox]`);值存宿主 settings(`historyEnabled`,默认启用,原生设置页与本面板同存储即时同值),读写经本插件 `GET/POST /api/session-manager/history-enabled`(client 写 settings 不经宿主 remotes,零版本脆弱依赖);浮层挂载拉取一次,停用即锚点/快捷键/浮层整体不渲染、键盘监听放行,变更刷新页面生效。
10
10
  - 自动归档: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
+ - 自动归档配置:归档面板「已删除」区下方的配置行,阈值天数与检查周期小时数两个数字输入,失焦或 Enter 单字段即时提交,非法输入前端拦截不发请求,服务端失败回读生效值回滚;读写经本插件 `GET/POST /api/session-manager/auto-archive`(POST 部分更新,仅接受出现且为非负整数的字段,空字段拒绝),值存宿主 settings 与评估逻辑同源,`0` = 关闭,GET 返回含默认回退的当前生效值。
11
12
  - 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow` 归档快照的交集,按更新时间倒序;行内标题前显示所属工作区徽标——映射优先走 workspace 账本(`sessionIds`,与官方 dsh-client-ui-workspace 的 workspaceBySession 同构),cwd 命中某登记工作区路径时回退该工作区标题(较官方「cwd basename 直接回退」收紧,防未登记目录名冒充工作区),均未命中显示「未分组」;工作区 title 缺失回退路径末段。镜像函数 client 与 core 双实现由 parity 测试锁定。行内支持取消归档与删除。
12
- - 归档与操作通知:host 归档动作经 `workspace.follow` 的 `archived` 增量帧到达 client,集合差分得到新增条数 N,经公共通知依赖 `@mzzsfy/dsh-toast` 顶部居中显示「有 N 个会话已归档」;面板操作(恢复 / 删除 / 重新挂载等)的成功与失败反馈同样经该依赖展示,失败提示常驻待确认。dsh-toast 为普通 npm 依赖随本插件安装,其宿主占位条目由本插件 cordis.patch.yml 代挂。
13
+ - 归档与操作通知: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 代挂。
13
14
  - 删除:仅对已归档会话生效(运行中守卫先于执行,执行前 TOCTOU 复检,运行中的会话不可删除;OS 回收存在数百 ms 异步窗口,复检通过后仍可能恢复运行——此时回收已完成,响应附警告并落服务端日志),两段式确认(展示标题 / 日志体积,产物已缺失时以「产物已丢失」替代体积)后按 locate → trash → 台账 → detach → 归档清理单序执行;同 id 并发删除仅首个生效,其余以「正在删除中」拒绝。会话产物目录整体移入系统回收站(Windows PowerShell VisualBasic / macOS Finder / Linux gio;Windows 路径经环境变量传入 PowerShell,含空格或特殊字符的路径安全),可还原,不做直接删除降级。trash 成功即写入已删除台账(独立 storage domain `session_manager`,读改写经进程内互斥链串行化且单次操作带超时兜底,条目上限 200 裁最旧),记录会话 id 与产物原位置。产物**目录**已缺失的会话,仅当已归档或台账有记录(同 id 重删的残留收尾)时跳过回收与台账、仅解除列表关联,提示「产物已不存在,已完成列表清理」;两者皆无的缺失可能是新建会话尚未落盘,直接拒绝以防剥离活会话。
14
15
  - 已删除分区与重挂载:面板「已删除」区列出台账条目(标题 / 原位置 / 删除时间)。用户在系统回收站将会话文件夹还原到原位置后点「重新挂载」,经官方 `workspace.attachSession` 挂回工作区列表并清除台账;「移除记录」幂等清除单条。还原动作本身始终由用户在系统回收站完成,插件不驱动系统回收站。
15
16
 
@@ -59,7 +60,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
59
60
  - 已删除台账:独立 storage domain `session_manager`(global 单列表),非 workspace 域、非审计层——存在意义仅为重挂载定位产物原位置。trash 成功即记录(位于 detach 前),台账失败仅降级重挂载便利,不回滚删除;同 id 再次删除替换旧条目。台账读改写经进程内互斥链串行化(并发删除不再有丢更新窗口),条目上限 200、超出裁最旧,防无界增长写放大。台账不随产物还原自动清除,以「重新挂载」或「移除记录」收尾;清空回收站后的残留条目无恢复价值,应手动移除。
60
61
  - 评估的活跃时间:JSONL 后端以产物 mtime 作为最近活跃代理,updatedAt = max(createdAt, mtime);非 JSONL 后端退化为 createdAt。评估两级筛选:先用 createdAt 纯内存预筛(mtime 只会增大 updatedAt,createdAt 未超期必不超期),仅对预筛存活者做产物 stat / blank 探测,活跃会话零 IO。空白判定:live 会话 seq=0,冷会话以 JSONL 产物「仅 header 一行」判定,locate 不可用的后端按非空白处理。评估整体带超时兜底(宿主服务挂起时门闩必然复位,不会永久停摆);单会话归档失败仅告警不中断整轮。
61
62
  - 无批量操作、无内容搜索、无会话详情预览。
62
- - 自动归档评估门闩:评估进行中其他触发源(含周期轮与 session/created)的新触发直接丢弃,不做排队;漏掉的会话由下一次触发兜底,启动补扫与每日周期轮保证了积压会话最迟一个周期内归档。产物不可读(mtime 探测失败)的会话视为已删除,不参与归档,防止删除后的会话被重新归档复活。timer 软依赖:宿主定时服务缺失或调用失败时周期轮停用,设置页显示降级提示(session/created 触发与启动补扫不受影响);timer 服务晚于插件激活时周期轮自动补武装。
63
+ - 自动归档评估门闩:评估进行中其他触发源(含周期轮与 session/created)的新触发直接丢弃,不做排队;漏掉的会话由下一次触发兜底,启动补扫与每日周期轮保证了积压会话最迟一个周期内归档。产物不可读(mtime 探测失败)的会话视为已删除,不参与归档,防止删除后的会话被重新归档复活。subagent 内部会话(`origin: 'subagent'`)永不参与归档:官方主列表即以 origin 排除 subagent(归档集合仅服务用户会话),误归档只产出无 title 回退 cwd 名的不可辨认面板行与无意义通知。timer 软依赖:宿主定时服务缺失或调用失败时周期轮停用,设置页显示降级提示(session/created 触发与启动补扫不受影响);timer 服务晚于插件激活时周期轮自动补武装。
63
64
  - 归档提示以「连续两个 ready 快照」为差分启用条件:基线首装(启动与重连)不提示存量,重连基线携带的离期新增仍会提示。通知展示经公共依赖 `@mzzsfy/dsh-toast`(external require),本包不再自带通知 UI。client.js 为单文件自包含格式,守卫逻辑与 core.mjs 各存一份镜像,修改需两处同步(projectRows / archiveToastStep / projectDeletedRows 同规);test/parity.test.mjs 对各组镜像做行为级对照并锁定 client 侧确认态文案与 host MESSAGES 同值。client 已删除列表加载失败时保留旧数据并 console.warn,不渲染成「无记录」。路由错误响应:业务错误原样透传中文文案,系统级错误(带 fs 错误码,message 内嵌绝对路径)收敛为固定文案并落服务端日志。
64
65
  - 历史输入的已知取舍:对齐扫描各工作区最近 `HISTORY_SESSION_SCAN_LIMIT` 个会话(更早会话的输入不入缓存),解压发生在后台、永不阻塞浮层响应,代价是首次对齐期间列表为空、需等静默重拉;**解压不重复执行**:每会话提取结果持久化在工作区缓存 `extracts` 段并附产物 stat 指纹(mtimeMs+size),对齐时先 stat 比对、产物未变零解压,判断完全基于磁盘、跨重启生效;运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效;启动零解压(历史直接读磁盘缓存,重启前的输入本就在 entries 里),全量对齐延迟 `HISTORY_STARTUP_DELAY_MS` 再跑且只回溯最近 `HISTORY_STARTUP_SCAN_LIMIT` 个会话覆盖的工作区;对齐中连续解压占用超 `HISTORY_ALIGN_SLICE_MS` 即让出 `HISTORY_ALIGN_YIELD_MS`(zstd 解压为同步 CPU 操作,不设让出会让宿主启动阻塞数十秒);超 `HISTORY_ALIGN_MAX_ARTIFACT_BYTES` 的巨产物跳过自动对齐(单次 readSession 内部同步解压不可让出,实时性让位于不卡主循环);header 查找走 ctx 级 TTL 缓存(inputs 路由不每次全量扫描 listSessions);对齐为双车道:批量车道(窗口对齐)与焦点车道(单会话实时追加)各自串行互不等待,会话范围请求检测到产物指纹失配即触发焦点对齐并返回 aligned=false;浮层打开期间固定节奏轮询当前范围(数据可能晚于首次响应到达,一次性重拉预算会让迟到数据永不出现);单会话首条数据可见预算 3s:会话范围路由触发焦点对齐后就地等待 `HISTORY_FOCUS_WAIT_MS`(2s),正常会话单次请求内拿到新数据,超时(巨产物)走轮询流式补齐;不解压搬进 worker:产物为多帧 zstd + 帧扫描 + 记录解码 + 版本协商,`readSession` 深度耦合宿主持持层,worker 内复刻等于维护第二份产物解析实现,宿主升级即漂移;缓存上限 `HISTORY_INPUT_LIMIT` 条,超出裁掉最旧(超长会话史会丢早期输入);新输入从发送到浮层可见有秒级延迟(宿主批量刷盘 200ms + 焦点对齐 + 轮询节奏),非严格实时;缓存文件为明文 JSON(输入内容落盘 `~/.dsh/historyPrompt`,与产物本身同级敏感);单条超长文本(大段粘贴)在 host 侧截断,回填与展示均用截断后文本;交互收敛为快捷键唤起浮层(`Alt+↑`,无 mac 系统占用,与编辑器光标移动无冲突),无常驻界面元素。
65
66
  - 重挂载的并发窗口:attachSession 后、台账清除前进程中断,残留条目重挂载时因官方 attachSession 幂等(已在 sessionIds 中则跳过校验直写)无副作用,再次点按或「移除记录」即可收尾。台账域打开失败时已删除 / 重挂载 / forget 以 400 拒绝;删除路由走 partial 降级(其余步骤照常,提示「已移入回收站,但重挂载记录失败」),不做无台账中断——产物进回收站后中断删除只会造成状态不一致。
@@ -73,10 +74,10 @@ npm test
73
74
 
74
75
  纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip,`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
75
76
 
76
- ## 开发安装(不经 npm 发布直接装仓库副本)
77
+ ## 开发安装(仓库工作副本直挂,不经 npm 发布)
77
78
 
78
79
  ```sh
79
- dsh plugin --profile web add file:./packages/dsh-session-manager
80
+ node scripts/dev-link.mjs dsh-session-manager # 仓库根执行:归一 profile 依赖行 + 挂 junction
80
81
  ```
81
82
 
82
- `file:` 安装指向仓库工作副本,改代码后重跑该命令即同步,无需发版。
83
+ 工作副本以 junction 挂进 profile,改代码保存即热重载(host 半区)或刷新页面生效(client 半区),无需发版;规约与全仓归一见仓库根 `node scripts/dev-link.mjs all`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // 回填走宿主公共契约 inputActions.setDraft。浏览器半区经 webServer
8
8
  // 路由('/api/session-manager/*')访问 Host。打包为单文件自包含格式,无法跨文件
9
9
  // require;与 src/core.mjs 镜像的纯函数(projectArchiveRows / archiveToastStep /
10
- // projectDeletedRows)修改需两处同步。
10
+ // archiveToastText / projectDeletedRows)修改需两处同步。
11
11
 
12
12
  window.__ModuleLoader__.load({
13
13
  id: '@mzzsfy/dsh-session-manager',
@@ -84,6 +84,14 @@ const CSS = [
84
84
  '.sm-histsw:hover { color:var(--dsw-alias-label-primary, inherit); }',
85
85
  '@media (prefers-reduced-motion: reduce) { .sm-histsw__track, .sm-histsw__thumb { transition:none; } }',
86
86
  '@media (prefers-reduced-motion: reduce) { .sm-row__actions { transition:none; } }',
87
+ // 自动归档配置行:数值输入内联呈现,窄面板可换行
88
+ '.sm-cfg { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:10px;',
89
+ ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
90
+ '.sm-cfg__field { display:inline-flex; align-items:center; gap:4px; }',
91
+ '.sm-cfg__field input { width:56px; padding:2px 6px; border-radius:6px; color-scheme:light dark;',
92
+ ' border:1px solid light-dark(rgba(15,17,21,.18), rgba(255,255,255,.22));',
93
+ ' background:transparent; color:inherit; font:inherit; }',
94
+ '.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #1677ff); outline-offset:0; }',
87
95
  // 历史输入:零高度锚点容器 + 浮层(Alt+↑ 唤起);浮层与输入框同宽对齐,
88
96
  // 不透明实底 + 宿主同款卡片投影,与消息流明确区隔。
89
97
  // 色值取自宿主实测(白底卡片/墨色文字/蓝色强调):dsw alias 变量在宿主为空,不可依赖
@@ -279,6 +287,28 @@ function archiveToastStep(previous, snapshot) {
279
287
  return { state: { ready, ids }, added }
280
288
  }
281
289
 
290
+ // 归档通知标题列举上限:超过该数只列前段并以「 等」收尾(镜像 core.mjs 同名常量)
291
+ const TOAST_MAX_TITLES = 3
292
+
293
+ // 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
294
+ // 「 等」收尾,计数恒为真实总数;标题缺失、空白或行不存在回退会话 id
295
+ // (镜像 core.mjs archiveToastText,修改需两处同步)
296
+ function archiveToastText(addedIds, rows) {
297
+ const added = Array.isArray(addedIds) ? addedIds : []
298
+ if (added.length === 0) return ''
299
+ const safeRows = Array.isArray(rows) ? rows : []
300
+ const titleById = new Map()
301
+ for (const row of safeRows) {
302
+ if (!row || row.id === undefined || row.id === null) continue
303
+ const trimmed = String(row.title ?? '').trim()
304
+ if (trimmed !== '') titleById.set(String(row.id), trimmed)
305
+ }
306
+ const names = added.map((id) => titleById.get(String(id)) || String(id))
307
+ if (names.length === 1) return '会话「' + names[0] + '」已归档'
308
+ const listed = names.slice(0, TOAST_MAX_TITLES)
309
+ return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
310
+ }
311
+
282
312
  function ArchiveRow(props) {
283
313
  const row = props.row
284
314
  const armed = props.armed
@@ -455,6 +485,7 @@ function SessionManagerApp(props) {
455
485
  onRemount,
456
486
  onForget,
457
487
  }),
488
+ h(AutoArchiveConfig, null),
458
489
  h(HistorySwitchRow, null),
459
490
  )
460
491
  }
@@ -489,6 +520,87 @@ function HistorySwitchRow() {
489
520
  )
490
521
  }
491
522
 
523
+ // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
524
+ // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
525
+ // 服务端失败后回读生效值回滚显示
526
+ const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
527
+
528
+ // 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
529
+ // invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
530
+ // 必须对照已提交值而非显示值
531
+ function classifyAutoArchiveInput(committedText, text) {
532
+ const trimmed = text.trim()
533
+ if (trimmed === committedText) return { action: 'noop' }
534
+ const value = Number(trimmed)
535
+ if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
536
+ return { action: 'post', value }
537
+ }
538
+
539
+ function AutoArchiveConfig() {
540
+ const [state, setState] = useState(null)
541
+ // 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
542
+ const committedRef = React.useRef(null)
543
+ useEffect(() => {
544
+ api(AUTO_ARCHIVE_URL)
545
+ .then((payload) => {
546
+ if (!payload) return
547
+ const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
548
+ committedRef.current = next
549
+ setState(next)
550
+ })
551
+ .catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
552
+ }, [])
553
+ function refresh() {
554
+ return api(AUTO_ARCHIVE_URL)
555
+ .then((payload) => {
556
+ if (!payload) return
557
+ const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
558
+ committedRef.current = next
559
+ setState(next)
560
+ })
561
+ .catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
562
+ }
563
+ function commit(field, text) {
564
+ const committed = committedRef.current
565
+ if (committed === null) return
566
+ // 提交判定与 core.mjs classifyAutoArchiveInput 镜像同规:受控输入下显示值已随
567
+ // 击键同步,守卫必须对已提交基准比较,与显示值比较恒相等
568
+ const verdict = classifyAutoArchiveInput(committed[field], text)
569
+ if (verdict.action === 'noop') return
570
+ if (verdict.action === 'invalid') {
571
+ setState({ ...committed })
572
+ toast('自动归档配置须为非负整数', { kind: 'error' })
573
+ return
574
+ }
575
+ const value = verdict.value
576
+ api(AUTO_ARCHIVE_URL, { method: 'POST', body: JSON.stringify({ [field]: value }) })
577
+ .then((payload) => {
578
+ // 仅合回已提交字段,不清掉另一字段的未提交编辑
579
+ const saved = String(payload[field])
580
+ committedRef.current = { ...committedRef.current, [field]: saved }
581
+ setState((prev) => (prev === null ? prev : { ...prev, [field]: saved }))
582
+ toast('自动归档配置已保存')
583
+ })
584
+ .catch(() => {
585
+ toast('自动归档配置保存失败', { kind: 'error' })
586
+ void refresh()
587
+ })
588
+ }
589
+ if (state === null) return null
590
+ const numberInput = (field) => h('input', {
591
+ type: 'number', min: 0, step: 1, value: state[field],
592
+ onChange: (event) => setState((prev) => (prev === null ? prev : { ...prev, [field]: event.target.value })),
593
+ // Enter 经 blur 走同一提交路径,避免与 onBlur 双触发重复提交
594
+ onKeyDown: (event) => { if (event.key === 'Enter') event.target.blur() },
595
+ onBlur: (event) => commit(field, event.target.value),
596
+ })
597
+ return h('div', { className: 'sm-cfg' },
598
+ h('span', null, '自动归档'),
599
+ h('label', { className: 'sm-cfg__field' }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
600
+ h('label', { className: 'sm-cfg__field' }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
601
+ )
602
+ }
603
+
492
604
  // 历史输入浮层:Alt+↑ 快捷键唤起,平时零占位;浮层内点选或键盘(↑/↓ 选择、
493
605
  // ←/→ 切范围、Enter 填入、Esc 关闭)回填历史,填入走宿主公共契约
494
606
  // inputActions.setDraft,不直改编辑器 DOM。数据由 host 工作区持久缓存
@@ -838,12 +950,18 @@ function HistoryDock({ session, inputActions }) {
838
950
  return () => style.remove()
839
951
  }, 'session-manager styles')
840
952
 
841
- // 归档快照差分:新增条数驱动通知
953
+ // 归档快照差分:新增驱动通知,文案携带会话标题
842
954
  let previous
843
955
  const unsubscribe = workspaces.list.subscribe(() => {
844
956
  const step = archiveToastStep(previous, workspaces.list.getSnapshot())
845
957
  previous = step.state
846
- if (step.added.length > 0) toast('有 ' + step.added.length + ' 个会话已归档')
958
+ if (step.added.length > 0) {
959
+ // 会话行快照与面板投影同源取 byId,标题映射为通知文案行数据
960
+ const listState = sessions.list.getSnapshot()
961
+ const byId = (listState && listState.byId) || {}
962
+ const rows = Object.keys(byId).map((id) => ({ id, title: byId[id].displayTitle }))
963
+ toast(archiveToastText(step.added, rows))
964
+ }
847
965
  })
848
966
 
849
967
  ctx.slots.inject('settings.section', () =>
package/src/core.mjs CHANGED
@@ -5,6 +5,20 @@ export const DAY_MS = 24 * 60 * 60 * 1000
5
5
  export const DEFAULT_AUTO_ARCHIVE_DAYS = 7
6
6
  export const DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS = 24
7
7
 
8
+ /**
9
+ * 自动归档配置的提交判定:输入框文本对照已提交值分类。
10
+ * @param committedText - 该字段已提交值的字符串形态
11
+ * @param text - 输入框当前文本
12
+ * @returns noop 值未变 | invalid 非非负整数 | post 可提交(携带解析后的数值)
13
+ */
14
+ export function classifyAutoArchiveInput(committedText, text) {
15
+ const trimmed = text.trim()
16
+ if (trimmed === committedText) return { action: 'noop' }
17
+ const value = Number(trimmed)
18
+ if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
19
+ return { action: 'post', value }
20
+ }
21
+
8
22
  /** 更新时间 = max(createdAt, 最近活跃时间)。 */
9
23
  export function updatedAtOf(header, activityAtMs) {
10
24
  return Math.max(header.createdAt, activityAtMs ?? 0)
@@ -89,6 +103,31 @@ export function archiveToastStep(previous, snapshot) {
89
103
  return { state: { ready, ids }, added }
90
104
  }
91
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
+
92
131
  /** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
93
132
  export function deleteEligibility({ archivedIds, sessionId }) {
94
133
  return archivedIds.includes(sessionId)
package/src/index.js CHANGED
@@ -139,6 +139,22 @@ function readSettings(ctx) {
139
139
  }
140
140
  }
141
141
 
142
+ // auto-archive 路由请求体 → settings patch:仅接受出现且为非负整数的字段(部分更新),
143
+ // 一个合法字段都没有即拒绝;返回形态错误以业务错误抛出,由路由统一转 400
144
+ const AUTO_ARCHIVE_FIELDS = [['days', 'autoArchiveDays'], ['intervalHours', 'autoArchiveIntervalHours']]
145
+
146
+ function readAutoArchivePatch(body) {
147
+ const patch = {}
148
+ for (const [input, setting] of AUTO_ARCHIVE_FIELDS) {
149
+ const raw = body ? body[input] : undefined
150
+ if (raw === undefined) continue
151
+ if (!Number.isInteger(raw) || raw < 0) throw new Error('自动归档配置须为非负整数: ' + input)
152
+ patch[setting] = raw
153
+ }
154
+ if (Object.keys(patch).length === 0) throw new Error('缺少可更新的配置字段')
155
+ return patch
156
+ }
157
+
142
158
  function readBody(req) {
143
159
  const BODY_MAX_BYTES = 64 * 1024
144
160
  return new Promise((resolve, reject) => {
@@ -234,6 +250,9 @@ export function apply(ctx, config) {
234
250
  for (const recordItem of records) {
235
251
  const header = recordItem.header
236
252
  if (cwd !== undefined && header.cwd !== cwd) continue
253
+ // subagent 内部会话不属主列表(官方 sessionVisible 以 origin 排除),归档集合
254
+ // 仅服务用户会话:误归档只产出不可辨认的面板行与无意义通知
255
+ if (header.origin === 'subagent') continue
237
256
  // 预筛:createdAt 未超期必不超期(见上),跳过产物 IO
238
257
  if (header.createdAt >= cutoff) continue
239
258
  // 已归档记录永不是候选,集合随历史增长,提前跳过省产物 IO
@@ -266,6 +285,7 @@ export function apply(ctx, config) {
266
285
  for (const id of selectArchiveCandidates({ records: candidates, nowMs, thresholdDays: days })) {
267
286
  try {
268
287
  await registry.archiveSession(id)
288
+ ctx.logger && ctx.logger.info && ctx.logger.info('session-manager 已归档会话: ' + id)
269
289
  } catch (error) {
270
290
  // 单个归档失败不中断整轮,剩余候选继续;失败者由下一次触发重试
271
291
  ctx.logger && ctx.logger.warn('session-manager 归档 ' + id + ' 失败: ' + String(error))
@@ -869,13 +889,44 @@ export function apply(ctx, config) {
869
889
  }
870
890
  const settings = ctx.get('settings')
871
891
  if (!settings) throw new Error('宿主设置服务不可用')
872
- settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
892
+ // update 异步落盘后才提交新值:await 保证持久化完成后再读回
893
+ await settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
873
894
  sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
874
895
  } catch (error) {
875
896
  respondError(ctx, res, error)
876
897
  }
877
898
  },
878
899
  },
900
+ {
901
+ // 自动归档配置:读回生效值(含默认回退),写经 settings.update 部分更新;
902
+ // 0 = 关闭(阈值天数关自动归档,间隔小时数关周期轮),与评估逻辑同源语义
903
+ path: '/api/session-manager/auto-archive',
904
+ handler: async (req, res) => {
905
+ try {
906
+ if (req.method === 'GET') {
907
+ const current = readSettings(ctx)
908
+ sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
909
+ return
910
+ }
911
+ if (!rejectMethod(req, res, 'POST')) return
912
+ let body
913
+ try {
914
+ body = JSON.parse(await readBody(req))
915
+ } catch {
916
+ throw new Error(MESSAGES.badJsonBody)
917
+ }
918
+ const patch = readAutoArchivePatch(body)
919
+ const settings = ctx.get('settings')
920
+ if (!settings) throw new Error('宿主设置服务不可用')
921
+ // update 异步落盘后才提交新值:await 保证回显为新值,persist 失败走 respondError
922
+ await settings.update(NAMESPACE, patch)
923
+ const current = readSettings(ctx)
924
+ sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
925
+ } catch (error) {
926
+ respondError(ctx, res, error)
927
+ }
928
+ },
929
+ },
879
930
  {
880
931
  path: '/api/session-manager/deleted',
881
932
  handler: async (req, res) => {
@@ -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'])
@@ -8,9 +8,11 @@ import {
8
8
  DAY_MS,
9
9
  DEFAULT_AUTO_ARCHIVE_DAYS,
10
10
  DELETE_MESSAGES,
11
+ TOAST_MAX_TITLES,
11
12
  aggregateDeleteOutcome,
12
13
  aggregateInputs,
13
14
  archiveToastStep,
15
+ archiveToastText,
14
16
  artifactLooksBlank,
15
17
  deleteEligibility,
16
18
  diffArchived,
@@ -215,6 +217,51 @@ test('Toast 差分:ready→pending→ready 重连序列不误报存量', () => {
215
217
  assert.deepEqual(archiveToastStep(previous, { phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] }).added, ['c'])
216
218
  })
217
219
 
220
+ test('归档通知文案:截断阈值常量', () => {
221
+ assert.equal(TOAST_MAX_TITLES, 3)
222
+ })
223
+
224
+ test('归档通知文案:单个会话报标题', () => {
225
+ assert.equal(archiveToastText(['a'], [{ id: 'a', title: '修复登录页' }]), '会话「修复登录页」已归档')
226
+ })
227
+
228
+ test('归档通知文案:多个会话列举标题,计数为真实总数', () => {
229
+ const rows = [
230
+ { id: 'a', title: 'A' },
231
+ { id: 'b', title: 'B' },
232
+ ]
233
+ assert.equal(archiveToastText(['a', 'b'], rows), '有 2 个会话已归档:A、B')
234
+ })
235
+
236
+ test('归档通知文案:恰好等于展示阈值不截断', () => {
237
+ const rows = [
238
+ { id: 'a', title: 'A' },
239
+ { id: 'b', title: 'B' },
240
+ { id: 'c', title: 'C' },
241
+ ]
242
+ assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:A、B、C')
243
+ })
244
+
245
+ test('归档通知文案:超展示阈值只列前段并以「 等」收尾,计数仍为真实总数', () => {
246
+ const rows = [
247
+ { id: 'a', title: 'A' },
248
+ { id: 'b', title: 'B' },
249
+ { id: 'c', title: 'C' },
250
+ { id: 'd', title: 'D' },
251
+ ]
252
+ assert.equal(archiveToastText(['a', 'b', 'c', 'd'], rows), '有 4 个会话已归档:A、B、C 等')
253
+ })
254
+
255
+ test('归档通知文案:标题缺失、空白与行缺失回退会话 id,标题按去除首尾空白取值', () => {
256
+ const rows = [{ id: 'a' }, { id: 'b', title: ' ' }, { id: 'x', title: 'X' }]
257
+ assert.equal(archiveToastText(['a', 'b', 'c'], rows), '有 3 个会话已归档:a、b、c')
258
+ assert.equal(archiveToastText(['t'], [{ id: 't', title: ' T ' }]), '会话「T」已归档')
259
+ })
260
+
261
+ test('归档通知文案:行数据非数组按空处理,标题全部回退会话 id', () => {
262
+ assert.equal(archiveToastText(['a', 'b'], undefined), '有 2 个会话已归档:a、b')
263
+ })
264
+
218
265
  test('非归档会话拒绝删除', () => {
219
266
  assert.equal(deleteEligibility({ archivedIds: ['a'], sessionId: 'a' }).ok, true)
220
267
  const denied = deleteEligibility({ archivedIds: ['a'], sessionId: 'b' })
@@ -22,7 +22,7 @@ const declaredInject = Array.isArray(indexModule.inject) ? indexModule.inject :
22
22
  const dependencyReady = typeof apply === 'function'
23
23
  const skipMissingDeps = { skip: dependencyReady ? false : 'peer 依赖未安装,路由层测试跳过' }
24
24
  const MESSAGES = indexModule.MESSAGES
25
- const { DELETE_MESSAGES, HISTORY_INPUT_LIMIT, HISTORY_STARTUP_SCAN_LIMIT, HISTORY_INPUT_MAX_CHARS, HISTORY_PROMPTS_MAX, HISTORY_SCOPES } = await import('../src/core.mjs').catch(() => ({ DELETE_MESSAGES: {} }))
25
+ const { DELETE_MESSAGES, HISTORY_INPUT_LIMIT, HISTORY_STARTUP_SCAN_LIMIT, HISTORY_INPUT_MAX_CHARS, HISTORY_PROMPTS_MAX, HISTORY_SCOPES, DEFAULT_AUTO_ARCHIVE_DAYS, DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS } = await import('../src/core.mjs').catch(() => ({ DELETE_MESSAGES: {} }))
26
26
  const { ensureCacheDir, writeWorkspaceCache } = await import('../src/history-cache.mjs')
27
27
 
28
28
  // 插件激活即跑历史缓存启动对齐:所有测试统一隔离缓存目录,
@@ -129,7 +129,11 @@ function makeCtx({
129
129
  const settingsService = {
130
130
  get: () => settingsState.value,
131
131
  register: () => ({ resolved: undefined }),
132
- update: (ns, patch) => { settingsState.value = { ...(settingsState.value || {}), ...patch } },
132
+ // 宿主 update 为异步串行:合并进微任务队列后生效,读旧值发生在 flush 前
133
+ update: (ns, patch) => new Promise((resolve) => queueMicrotask(() => {
134
+ settingsState.value = { ...(settingsState.value || {}), ...patch }
135
+ resolve()
136
+ })),
133
137
  }
134
138
  // timer 服务桩:模拟宿主 timer 激活后的 interval;unref 保证测试进程可自然退出
135
139
  const intervalStub = (fn, ms) => {
@@ -164,7 +168,7 @@ function makeCtx({
164
168
  get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
165
169
  on: (event, handler) => { eventHandlers[event] = handler },
166
170
  // 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
167
- 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) } },
168
172
  }
169
173
  const ctx = new Proxy(base, {
170
174
  get(target, prop) {
@@ -550,7 +554,7 @@ test('自动归档评估:产物不可读的会话不参与归档(防删除后复
550
554
  const sessionPersistence = {
551
555
  locate: (header) => header.id === 's1' ? { path: alivePath } : { path: path.join(dir, 'gone.jsonl') },
552
556
  }
553
- const { eventHandlers, registry } = makeCtx({
557
+ const { eventHandlers, registry, logger } = makeCtx({
554
558
  archivedIds: [],
555
559
  headers,
556
560
  agents: new Map(),
@@ -560,6 +564,9 @@ test('自动归档评估:产物不可读的会话不参与归档(防删除后复
560
564
  await waitFor(() => registry.archiveCalls.length > 0)
561
565
  // s2 产物缺失必须被守卫跳过:否则已删除会话会被重新归档而在面板复活
562
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 日志')
563
570
  })
564
571
 
565
572
  test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档', skipMissingDeps, async () => {
@@ -593,6 +600,38 @@ test('自动归档评估:locate 缺失(第三方后端)的会话不参与归档'
593
600
  }
594
601
  })
595
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
+
596
635
  test('启动评估:settings 就绪后全量归档超期会话,不限于单工作区', skipMissingDeps, async () => {
597
636
  const dir = await mkdtemp(path.join(tmpdir(), 'sm-startup-'))
598
637
  try {
@@ -1847,6 +1886,78 @@ test('历史浮层启停:GET 默认启用,POST 切换经 settings 持久,GET 反
1847
1886
  assert.equal(restored.body.enabled, true)
1848
1887
  })
1849
1888
 
1889
+ test('自动归档配置:GET 回显生效值(未配置回默认),POST 持久,GET 反映新值', skipMissingDeps, async () => {
1890
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1891
+ const initial = response()
1892
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), initial)
1893
+ assert.deepEqual(initial.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '未配置时回显默认值')
1894
+ const changed = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3, intervalHours: 12 })
1895
+ assert.deepEqual(changed.body, { days: 3, intervalHours: 12 })
1896
+ const reread = response()
1897
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1898
+ assert.deepEqual(reread.body, { days: 3, intervalHours: 12 }, 'settings 持久化新值')
1899
+ })
1900
+
1901
+ test('自动归档配置:单字段部分更新,未提交字段保持不变', skipMissingDeps, async () => {
1902
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1903
+ const onlyDays = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1904
+ assert.deepEqual(onlyDays.body, { days: 3, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '仅更新 days')
1905
+ const onlyHours = await postJson(handlers, '/api/session-manager/auto-archive', { intervalHours: 12 })
1906
+ assert.deepEqual(onlyHours.body, { days: 3, intervalHours: 12 }, '仅更新 intervalHours,days 保持')
1907
+ })
1908
+
1909
+ test('自动归档配置:0 值合法且持久(阈值天数关自动归档,间隔小时数关周期轮)', skipMissingDeps, async () => {
1910
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1911
+ const off = await postJson(handlers, '/api/session-manager/auto-archive', { days: 0, intervalHours: 0 })
1912
+ assert.deepEqual(off.body, { days: 0, intervalHours: 0 })
1913
+ const reread = response()
1914
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1915
+ assert.deepEqual(reread.body, { days: 0, intervalHours: 0 }, '关闭值持久化')
1916
+ })
1917
+
1918
+ test('自动归档配置:负数/非整数/非数值/空字段一律 400,配置不变', skipMissingDeps, async () => {
1919
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1920
+ const invalidPayloads = [
1921
+ { days: -1, intervalHours: 1 },
1922
+ { days: 1.5, intervalHours: 1 },
1923
+ { days: '3', intervalHours: 1 },
1924
+ { days: true, intervalHours: 1 },
1925
+ { days: null, intervalHours: 1 },
1926
+ {},
1927
+ ]
1928
+ for (const payload of invalidPayloads) {
1929
+ const rejected = await postJson(handlers, '/api/session-manager/auto-archive', payload)
1930
+ assert.equal(rejected.status, 400, JSON.stringify(payload))
1931
+ }
1932
+ const after = response()
1933
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), after)
1934
+ assert.deepEqual(after.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '被拒请求不改动配置')
1935
+ })
1936
+
1937
+ test('自动归档配置提交判定:noop/invalid/post 三态分类', async () => {
1938
+ const { classifyAutoArchiveInput } = await import('../src/core.mjs')
1939
+ assert.deepEqual(classifyAutoArchiveInput('7', '7'), { action: 'noop' }, '值未变不提交')
1940
+ assert.deepEqual(classifyAutoArchiveInput('7', ' 7 '), { action: 'noop' }, '空白差异修剪后视为未变')
1941
+ assert.deepEqual(classifyAutoArchiveInput('7', '-1'), { action: 'invalid' }, '负数拒绝')
1942
+ assert.deepEqual(classifyAutoArchiveInput('7', '1.5'), { action: 'invalid' }, '非整数拒绝')
1943
+ assert.deepEqual(classifyAutoArchiveInput('7', 'abc'), { action: 'invalid' }, '非数值拒绝')
1944
+ assert.deepEqual(classifyAutoArchiveInput('7', ''), { action: 'invalid' }, '空文本拒绝')
1945
+ assert.deepEqual(classifyAutoArchiveInput('7', '0'), { action: 'post', value: 0 }, '0 合法(关闭语义)')
1946
+ assert.deepEqual(classifyAutoArchiveInput('7', '3'), { action: 'post', value: 3 }, '正整数可提交')
1947
+ })
1948
+
1949
+ test('自动归档配置提交判定:client 与 core 镜像同源,提交守卫对照已提交值', { skip: process.env.TEST_SKIP_PARITY }, async () => {
1950
+ const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
1951
+ const declared = clientSource.match(/function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1952
+ assert.ok(declared, 'client.js 必须内联声明 classifyAutoArchiveInput')
1953
+ const coreSource = await readFile(new URL('../src/core.mjs', import.meta.url), 'utf8')
1954
+ const coreBody = coreSource.match(/export function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1955
+ assert.ok(coreBody, 'core.mjs 必须声明 classifyAutoArchiveInput')
1956
+ assert.equal(declared[1].trim(), coreBody[1].trim(), 'client 内联实现与 core 逐行一致')
1957
+ // 调用点契约:守卫实参必须是已提交基准 committed[field],对照显示值比较恒相等(受控输入击键已同步)
1958
+ assert.match(clientSource, /classifyAutoArchiveInput\(committed\[field\], text\)/, 'commit 必须以 committed[field] 为守卫基准')
1959
+ })
1960
+
1850
1961
  function getRequest2(url, method) {
1851
1962
  const req = new EventEmitter()
1852
1963
  req.method = method
@@ -10,6 +10,7 @@ import assert from 'node:assert/strict'
10
10
 
11
11
  import {
12
12
  archiveToastStep as coreArchiveToastStep,
13
+ archiveToastText as coreArchiveToastText,
13
14
  projectArchiveRows,
14
15
  projectDeletedRows,
15
16
  } from '../src/core.mjs'
@@ -27,7 +28,7 @@ assert.ok(MIRROR_START >= 0 && MIRROR_END > MIRROR_START, 'client.js 镜像函
27
28
 
28
29
  const mirror = new Function(
29
30
  CLIENT_SRC.slice(MIRROR_START, MIRROR_END)
30
- + '; return { projectRows: projectRows, projectDeletedRows: projectDeletedRows, archiveToastStep: archiveToastStep }',
31
+ + '; return { projectRows: projectRows, projectDeletedRows: projectDeletedRows, archiveToastStep: archiveToastStep, archiveToastText: archiveToastText }',
31
32
  )()
32
33
 
33
34
  // client 侧输入形态 byId 字典,core 侧 rows 数组:按 title/cwd 约定构造等价输入
@@ -144,6 +145,25 @@ test('parity Toast 差分:订阅即 ready(无 pending 帧)首帧守卫', () => {
144
145
  ])
145
146
  })
146
147
 
148
+ function assertToastTextParity(addedIds, rows) {
149
+ assert.equal(mirror.archiveToastText(addedIds, rows), coreArchiveToastText(addedIds, rows),
150
+ '归档通知文案不一致: ' + JSON.stringify(addedIds))
151
+ }
152
+
153
+ test('parity 归档通知文案:单标题/多标题/截断/回退/非数组行同输入同输出', () => {
154
+ const rows = [
155
+ { id: 'a', title: 'A' },
156
+ { id: 'b', title: ' B ' },
157
+ { id: 'c' },
158
+ { id: 'd', title: ' ' },
159
+ ]
160
+ assertToastTextParity(['a'], rows)
161
+ assertToastTextParity(['a', 'b'], rows)
162
+ assertToastTextParity(['a', 'b', 'c', 'd'], rows)
163
+ assertToastTextParity(['missing'], rows)
164
+ assertToastTextParity(['a', 'b'], undefined)
165
+ })
166
+
147
167
  test('parity Toast 差分:大集合增量性能形态一致性(n=5000)', () => {
148
168
  const big = Array.from({ length: 5000 }, (_, i) => 'id-' + i)
149
169
  assertToastStepParity([