@mzzsfy/dsh-session-manager 0.4.0 → 0.5.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,集合差分得到新增条数 N,经公共通知依赖 `@mzzsfy/dsh-toast` 顶部居中显示「有 N 个会话已归档」;面板操作(恢复 / 删除 / 重新挂载等)的成功与失败反馈同样经该依赖展示,失败提示常驻待确认。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
 
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.5.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -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 变量在宿主为空,不可依赖
@@ -455,6 +463,7 @@ function SessionManagerApp(props) {
455
463
  onRemount,
456
464
  onForget,
457
465
  }),
466
+ h(AutoArchiveConfig, null),
458
467
  h(HistorySwitchRow, null),
459
468
  )
460
469
  }
@@ -489,6 +498,87 @@ function HistorySwitchRow() {
489
498
  )
490
499
  }
491
500
 
501
+ // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
502
+ // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
503
+ // 服务端失败后回读生效值回滚显示
504
+ const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
505
+
506
+ // 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
507
+ // invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
508
+ // 必须对照已提交值而非显示值
509
+ function classifyAutoArchiveInput(committedText, text) {
510
+ const trimmed = text.trim()
511
+ if (trimmed === committedText) return { action: 'noop' }
512
+ const value = Number(trimmed)
513
+ if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
514
+ return { action: 'post', value }
515
+ }
516
+
517
+ function AutoArchiveConfig() {
518
+ const [state, setState] = useState(null)
519
+ // 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
520
+ const committedRef = React.useRef(null)
521
+ useEffect(() => {
522
+ api(AUTO_ARCHIVE_URL)
523
+ .then((payload) => {
524
+ if (!payload) return
525
+ const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
526
+ committedRef.current = next
527
+ setState(next)
528
+ })
529
+ .catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
530
+ }, [])
531
+ function refresh() {
532
+ return api(AUTO_ARCHIVE_URL)
533
+ .then((payload) => {
534
+ if (!payload) return
535
+ const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
536
+ committedRef.current = next
537
+ setState(next)
538
+ })
539
+ .catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
540
+ }
541
+ function commit(field, text) {
542
+ const committed = committedRef.current
543
+ if (committed === null) return
544
+ // 提交判定与 core.mjs classifyAutoArchiveInput 镜像同规:受控输入下显示值已随
545
+ // 击键同步,守卫必须对已提交基准比较,与显示值比较恒相等
546
+ const verdict = classifyAutoArchiveInput(committed[field], text)
547
+ if (verdict.action === 'noop') return
548
+ if (verdict.action === 'invalid') {
549
+ setState({ ...committed })
550
+ toast('自动归档配置须为非负整数', { kind: 'error' })
551
+ return
552
+ }
553
+ const value = verdict.value
554
+ api(AUTO_ARCHIVE_URL, { method: 'POST', body: JSON.stringify({ [field]: value }) })
555
+ .then((payload) => {
556
+ // 仅合回已提交字段,不清掉另一字段的未提交编辑
557
+ const saved = String(payload[field])
558
+ committedRef.current = { ...committedRef.current, [field]: saved }
559
+ setState((prev) => (prev === null ? prev : { ...prev, [field]: saved }))
560
+ toast('自动归档配置已保存')
561
+ })
562
+ .catch(() => {
563
+ toast('自动归档配置保存失败', { kind: 'error' })
564
+ void refresh()
565
+ })
566
+ }
567
+ if (state === null) return null
568
+ const numberInput = (field) => h('input', {
569
+ type: 'number', min: 0, step: 1, value: state[field],
570
+ onChange: (event) => setState((prev) => (prev === null ? prev : { ...prev, [field]: event.target.value })),
571
+ // Enter 经 blur 走同一提交路径,避免与 onBlur 双触发重复提交
572
+ onKeyDown: (event) => { if (event.key === 'Enter') event.target.blur() },
573
+ onBlur: (event) => commit(field, event.target.value),
574
+ })
575
+ return h('div', { className: 'sm-cfg' },
576
+ h('span', null, '自动归档'),
577
+ h('label', { className: 'sm-cfg__field' }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
578
+ h('label', { className: 'sm-cfg__field' }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
579
+ )
580
+ }
581
+
492
582
  // 历史输入浮层:Alt+↑ 快捷键唤起,平时零占位;浮层内点选或键盘(↑/↓ 选择、
493
583
  // ←/→ 切范围、Enter 填入、Esc 关闭)回填历史,填入走宿主公共契约
494
584
  // inputActions.setDraft,不直改编辑器 DOM。数据由 host 工作区持久缓存
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)
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) => {
@@ -869,13 +885,44 @@ export function apply(ctx, config) {
869
885
  }
870
886
  const settings = ctx.get('settings')
871
887
  if (!settings) throw new Error('宿主设置服务不可用')
872
- settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
888
+ // update 异步落盘后才提交新值:await 保证持久化完成后再读回
889
+ await settings.update(NAMESPACE, { historyEnabled: Boolean(body && body.enabled) })
873
890
  sendJson(res, 200, { enabled: readSettings(ctx).historyEnabled })
874
891
  } catch (error) {
875
892
  respondError(ctx, res, error)
876
893
  }
877
894
  },
878
895
  },
896
+ {
897
+ // 自动归档配置:读回生效值(含默认回退),写经 settings.update 部分更新;
898
+ // 0 = 关闭(阈值天数关自动归档,间隔小时数关周期轮),与评估逻辑同源语义
899
+ path: '/api/session-manager/auto-archive',
900
+ handler: async (req, res) => {
901
+ try {
902
+ if (req.method === 'GET') {
903
+ const current = readSettings(ctx)
904
+ sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
905
+ return
906
+ }
907
+ if (!rejectMethod(req, res, 'POST')) return
908
+ let body
909
+ try {
910
+ body = JSON.parse(await readBody(req))
911
+ } catch {
912
+ throw new Error(MESSAGES.badJsonBody)
913
+ }
914
+ const patch = readAutoArchivePatch(body)
915
+ const settings = ctx.get('settings')
916
+ if (!settings) throw new Error('宿主设置服务不可用')
917
+ // update 异步落盘后才提交新值:await 保证回显为新值,persist 失败走 respondError
918
+ await settings.update(NAMESPACE, patch)
919
+ const current = readSettings(ctx)
920
+ sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
921
+ } catch (error) {
922
+ respondError(ctx, res, error)
923
+ }
924
+ },
925
+ },
879
926
  {
880
927
  path: '/api/session-manager/deleted',
881
928
  handler: async (req, res) => {
@@ -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) => {
@@ -1847,6 +1851,78 @@ test('历史浮层启停:GET 默认启用,POST 切换经 settings 持久,GET 反
1847
1851
  assert.equal(restored.body.enabled, true)
1848
1852
  })
1849
1853
 
1854
+ test('自动归档配置:GET 回显生效值(未配置回默认),POST 持久,GET 反映新值', skipMissingDeps, async () => {
1855
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1856
+ const initial = response()
1857
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), initial)
1858
+ assert.deepEqual(initial.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '未配置时回显默认值')
1859
+ const changed = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3, intervalHours: 12 })
1860
+ assert.deepEqual(changed.body, { days: 3, intervalHours: 12 })
1861
+ const reread = response()
1862
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1863
+ assert.deepEqual(reread.body, { days: 3, intervalHours: 12 }, 'settings 持久化新值')
1864
+ })
1865
+
1866
+ test('自动归档配置:单字段部分更新,未提交字段保持不变', skipMissingDeps, async () => {
1867
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1868
+ const onlyDays = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1869
+ assert.deepEqual(onlyDays.body, { days: 3, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '仅更新 days')
1870
+ const onlyHours = await postJson(handlers, '/api/session-manager/auto-archive', { intervalHours: 12 })
1871
+ assert.deepEqual(onlyHours.body, { days: 3, intervalHours: 12 }, '仅更新 intervalHours,days 保持')
1872
+ })
1873
+
1874
+ test('自动归档配置:0 值合法且持久(阈值天数关自动归档,间隔小时数关周期轮)', skipMissingDeps, async () => {
1875
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1876
+ const off = await postJson(handlers, '/api/session-manager/auto-archive', { days: 0, intervalHours: 0 })
1877
+ assert.deepEqual(off.body, { days: 0, intervalHours: 0 })
1878
+ const reread = response()
1879
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), reread)
1880
+ assert.deepEqual(reread.body, { days: 0, intervalHours: 0 }, '关闭值持久化')
1881
+ })
1882
+
1883
+ test('自动归档配置:负数/非整数/非数值/空字段一律 400,配置不变', skipMissingDeps, async () => {
1884
+ const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1885
+ const invalidPayloads = [
1886
+ { days: -1, intervalHours: 1 },
1887
+ { days: 1.5, intervalHours: 1 },
1888
+ { days: '3', intervalHours: 1 },
1889
+ { days: true, intervalHours: 1 },
1890
+ { days: null, intervalHours: 1 },
1891
+ {},
1892
+ ]
1893
+ for (const payload of invalidPayloads) {
1894
+ const rejected = await postJson(handlers, '/api/session-manager/auto-archive', payload)
1895
+ assert.equal(rejected.status, 400, JSON.stringify(payload))
1896
+ }
1897
+ const after = response()
1898
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), after)
1899
+ assert.deepEqual(after.body, { days: DEFAULT_AUTO_ARCHIVE_DAYS, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS }, '被拒请求不改动配置')
1900
+ })
1901
+
1902
+ test('自动归档配置提交判定:noop/invalid/post 三态分类', async () => {
1903
+ const { classifyAutoArchiveInput } = await import('../src/core.mjs')
1904
+ assert.deepEqual(classifyAutoArchiveInput('7', '7'), { action: 'noop' }, '值未变不提交')
1905
+ assert.deepEqual(classifyAutoArchiveInput('7', ' 7 '), { action: 'noop' }, '空白差异修剪后视为未变')
1906
+ assert.deepEqual(classifyAutoArchiveInput('7', '-1'), { action: 'invalid' }, '负数拒绝')
1907
+ assert.deepEqual(classifyAutoArchiveInput('7', '1.5'), { action: 'invalid' }, '非整数拒绝')
1908
+ assert.deepEqual(classifyAutoArchiveInput('7', 'abc'), { action: 'invalid' }, '非数值拒绝')
1909
+ assert.deepEqual(classifyAutoArchiveInput('7', ''), { action: 'invalid' }, '空文本拒绝')
1910
+ assert.deepEqual(classifyAutoArchiveInput('7', '0'), { action: 'post', value: 0 }, '0 合法(关闭语义)')
1911
+ assert.deepEqual(classifyAutoArchiveInput('7', '3'), { action: 'post', value: 3 }, '正整数可提交')
1912
+ })
1913
+
1914
+ test('自动归档配置提交判定:client 与 core 镜像同源,提交守卫对照已提交值', { skip: process.env.TEST_SKIP_PARITY }, async () => {
1915
+ const clientSource = await readFile(new URL('../src/client.js', import.meta.url), 'utf8')
1916
+ const declared = clientSource.match(/function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1917
+ assert.ok(declared, 'client.js 必须内联声明 classifyAutoArchiveInput')
1918
+ const coreSource = await readFile(new URL('../src/core.mjs', import.meta.url), 'utf8')
1919
+ const coreBody = coreSource.match(/export function classifyAutoArchiveInput\(committedText, text\) \{([\s\S]*?)\n\}/)
1920
+ assert.ok(coreBody, 'core.mjs 必须声明 classifyAutoArchiveInput')
1921
+ assert.equal(declared[1].trim(), coreBody[1].trim(), 'client 内联实现与 core 逐行一致')
1922
+ // 调用点契约:守卫实参必须是已提交基准 committed[field],对照显示值比较恒相等(受控输入击键已同步)
1923
+ assert.match(clientSource, /classifyAutoArchiveInput\(committed\[field\], text\)/, 'commit 必须以 committed[field] 为守卫基准')
1924
+ })
1925
+
1850
1926
  function getRequest2(url, method) {
1851
1927
  const req = new EventEmitter()
1852
1928
  req.method = method