@mzzsfy/dsh-session-manager 0.9.2 → 0.9.4

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
@@ -89,4 +89,4 @@ node scripts/dev-link.mjs dsh-session-manager # 仓库根执行:归一 profile
89
89
 
90
90
  ## dsh 版本兼容
91
91
 
92
- 三版本全部通过:会话树渲染、归档入口、激活 live、diagnostics 0 findings。
92
+ 按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。兼容面以窗口实测为准,不逐版本承诺。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.9.2",
4
+ "version": "0.9.4",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -18,6 +18,13 @@
18
18
  "external": [
19
19
  "@mzzsfy/dsh-toast/client"
20
20
  ]
21
+ },
22
+ "compatibility": {
23
+ "dshReleases": {
24
+ "0.2.0-rc.1": "unknown",
25
+ "0.2.0-rc.2": "compatible",
26
+ "0.2.1-alpha.1": "unknown"
27
+ }
21
28
  }
22
29
  },
23
30
  "scripts": {
package/src/client.js CHANGED
@@ -665,7 +665,7 @@ function SessionManagerApp(props) {
665
665
 
666
666
  // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
667
667
  // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
668
- const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除。'
668
+ const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除;恢复后 10 分钟内暂停自动归档,期间发言即恢复正常活跃计时。'
669
669
  const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
670
670
  const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
671
671
 
package/src/core.mjs CHANGED
@@ -24,10 +24,19 @@ export function updatedAtOf(header, activityAtMs) {
24
24
  return Math.max(header.createdAt, activityAtMs ?? 0)
25
25
  }
26
26
 
27
+ // 取消归档全局宽限时长:恢复会话到用户发出首条消息之间的评估窗口保护,
28
+ // 首条消息更新产物 mtime 后由阈值自然接管;只查阅不发言的会话宽限后正常归档
29
+ export const UNARCHIVE_GRACE_MS = 10 * 60 * 1000
30
+
31
+ /** 宽限期判定:取消归档后 graceMs 内暂停自动归档(用户恢复即意图信号)。 */
32
+ export function unarchiveGraceActive(unarchivedAtMs, nowMs, graceMs = UNARCHIVE_GRACE_MS) {
33
+ if (!Number.isFinite(unarchivedAtMs) || unarchivedAtMs <= 0) return false
34
+ return nowMs - unarchivedAtMs < graceMs
35
+ }
36
+
27
37
  /**
28
38
  * 归档评估状态机:按阈值筛出待归档会话 id。
29
39
  * @param records - 候选行 {id, archived, running, blank, updatedAt}
30
- * @returns 超期且未归档、非运行中、非空白的会话 id
31
40
  */
32
41
  export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
33
42
  if (!Number.isFinite(thresholdDays) || thresholdDays <= 0) return []
package/src/index.js CHANGED
@@ -26,6 +26,8 @@ import {
26
26
  mergeDeletedEntry,
27
27
  removeDeletedEntry,
28
28
  selectArchiveCandidates,
29
+ UNARCHIVE_GRACE_MS,
30
+ unarchiveGraceActive,
29
31
  updatedAtOf,
30
32
  } from './core.mjs'
31
33
  import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
@@ -98,6 +100,21 @@ const SETTINGS_SCHEMA = schemastery.object({
98
100
  .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
99
101
  })
100
102
 
103
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
104
+ // settings.register 注册同名命名空间。根级 volatile 包装:0.1.7 下整节为 live
105
+ // 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
106
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
107
+
108
+ function volatileWrap(schema) {
109
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
110
+ }
111
+
112
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
113
+ // legacy 宿主 config 为普通对象原样透传
114
+ function unwrapVolatile(value) {
115
+ return typeof value?.get === 'function' ? value.get() : value
116
+ }
117
+
101
118
  function sendJson(res, status, payload) {
102
119
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
103
120
  res.end(JSON.stringify(payload))
@@ -114,9 +131,12 @@ function respondError(ctx, res, error) {
114
131
  sendJson(res, 400, { error: message })
115
132
  }
116
133
 
117
- function readSettings(ctx) {
134
+ function readSettings(ctx, config) {
118
135
  const settings = ctx.get('settings')
119
- const value = settings ? settings.get(NAMESPACE) : undefined
136
+ // 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
137
+ // config 整节解包。方法面守卫兼防:服务在但缺 get(宿主升级变更面)时回落默认值,
138
+ // 防 timer 回调抛错崩进程
139
+ const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
120
140
  const days = Number(value && value.autoArchiveDays)
121
141
  const hours = Number(value && value.autoArchiveIntervalHours)
122
142
  return {
@@ -199,6 +219,9 @@ function rejectMethod(req, res, method) {
199
219
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
200
220
  export function apply(ctx, config) {
201
221
  let evaluating = false
222
+ // 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
223
+ // 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
224
+ let unarchivedAtMs = 0
202
225
  // 删除路由同 id 并发去重(进程内)
203
226
  const inFlightDeletes = new Set()
204
227
 
@@ -228,7 +251,7 @@ export function apply(ctx, config) {
228
251
  // 活跃会话零 IO。返回是否真正启动:被门闩/关闭挡下的调用方(周期 tick)保持到期态
229
252
  // 由下个 tick 重试,不得据此重排周期
230
253
  function evaluateArchives(cwd) {
231
- const { days } = readSettings(ctx)
254
+ const { days } = readSettings(ctx, config)
232
255
  if (days === 0 || evaluating) return false
233
256
  evaluating = true
234
257
  let timeoutGuard
@@ -240,6 +263,8 @@ export function apply(ctx, config) {
240
263
  const persistence = ctx.get('sessionPersistence')
241
264
  const records = await ctx.sessionQuery.listSessions()
242
265
  const nowMs = Date.now()
266
+ // 全局宽限短路:恢复动作后 graceMs 内暂停归档,保护「恢复 → 首条消息」窗口
267
+ if (unarchiveGraceActive(unarchivedAtMs, nowMs)) return
243
268
  const cutoff = nowMs - days * DAY_MS
244
269
  const candidates = []
245
270
  for (const recordItem of records) {
@@ -367,6 +392,11 @@ export function apply(ctx, config) {
367
392
  })
368
393
  }
369
394
 
395
+ // 宽限记账:取消归档时更新宽限起点(进程内变量,无持久化副作用)
396
+ function recordUnarchiveGrace() {
397
+ unarchivedAtMs = Date.now()
398
+ }
399
+
370
400
  // 台账命中查询:同 id 曾删除过(重删场景的幽灵资格依据);域不可用时按未命中
371
401
  async function ledgerHasEntry(sessionId) {
372
402
  try {
@@ -433,7 +463,9 @@ export function apply(ctx, config) {
433
463
  handler: async (req, res) => {
434
464
  if (!rejectMethod(req, res, 'POST')) return
435
465
  try {
436
- await removeArchivedId(await requireSessionId(req))
466
+ const sessionId = await requireSessionId(req)
467
+ await removeArchivedId(sessionId)
468
+ await recordUnarchiveGrace()
437
469
  sendJson(res, 200, { ok: true })
438
470
  } catch (error) {
439
471
  respondError(ctx, res, error)
@@ -624,7 +656,7 @@ export function apply(ctx, config) {
624
656
  handler: async (req, res) => {
625
657
  try {
626
658
  if (req.method === 'GET') {
627
- const current = readSettings(ctx)
659
+ const current = readSettings(ctx, config)
628
660
  sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
629
661
  return
630
662
  }
@@ -636,11 +668,20 @@ export function apply(ctx, config) {
636
668
  throw new Error(MESSAGES.badJsonBody)
637
669
  }
638
670
  const patch = readAutoArchivePatch(body)
671
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
672
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
673
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
674
+ // await 保证回显为新值,persist 失败走 respondError
639
675
  const settings = ctx.get('settings')
640
- if (!settings) throw new Error('宿主设置服务不可用')
641
- // update 异步落盘后才提交新值:await 保证回显为新值,persist 失败走 respondError
642
- await settings.update(NAMESPACE, patch)
643
- const current = readSettings(ctx)
676
+ if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
677
+ await settings.update(NAMESPACE, patch)
678
+ } else {
679
+ const editor = ctx.get('configEditor')
680
+ const entry = ctx.fiber?.entry
681
+ if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
682
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
683
+ }
684
+ const current = readSettings(ctx, config)
644
685
  sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
645
686
  } catch (error) {
646
687
  respondError(ctx, res, error)
@@ -735,13 +776,13 @@ export function apply(ctx, config) {
735
776
  let nextDueAt = null
736
777
  let lastArmedAt = 0
737
778
  const scheduleNext = () => {
738
- const { intervalHours } = readSettings(ctx)
779
+ const { intervalHours } = readSettings(ctx, config)
739
780
  lastArmedAt = Date.now()
740
781
  nextDueAt = intervalHours > 0 ? lastArmedAt + intervalHours * HOUR_MS : null
741
782
  }
742
783
  try {
743
784
  const dispose = timerCtx.interval(() => {
744
- const { intervalHours } = readSettings(ctx)
785
+ const { intervalHours } = readSettings(ctx, config)
745
786
  // 设置变更对账:0 即时暂停;缺失 due(0 重启用)时补排,防周期轮静默死亡;
746
787
  // 缩短间隔时以 lastArmedAt 锚定前移,最长等新间隔而非等满旧周期
747
788
  if (intervalHours === 0) {
@@ -769,6 +810,15 @@ export function apply(ctx, config) {
769
810
  })
770
811
 
771
812
  ctx.inject(['settings'], (settingsCtx) => {
813
+ // 方法面守卫:settings 服务在但缺 register(宿主升级变更面)时跳过注册,
814
+ // 周期轮与启动补扫按 readSettings 的默认值降级运行,不因服务形变炸 fiber;
815
+ // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
816
+ if (typeof settingsCtx.settings.register !== 'function') {
817
+ if (typeof settingsCtx.settings?.configure === 'function') {
818
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
819
+ }
820
+ return
821
+ }
772
822
  settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
773
823
  // 启动补扫不依赖定时服务:settings 就绪即评估一轮,清掉停机期间积压的超期会话
774
824
  evaluateArchives()
@@ -26,6 +26,8 @@ import {
26
26
  projectDeletedRows,
27
27
  removeDeletedEntry,
28
28
  selectArchiveCandidates,
29
+ UNARCHIVE_GRACE_MS,
30
+ unarchiveGraceActive,
29
31
  updatedAtOf,
30
32
  workspaceTitleForSession,
31
33
  workspaceTitleOf,
@@ -80,6 +82,18 @@ test('阈值负值与非有限值防御性关闭', () => {
80
82
  assert.deepEqual(selectArchiveCandidates({ records: [record({})], nowMs: NOW, thresholdDays: Number.NaN }), [])
81
83
  })
82
84
 
85
+ test('全局宽限:取消归档后 graceMs 内宽限生效,到期与非法输入失效', () => {
86
+ assert.equal(unarchiveGraceActive(NOW - 2 * 60 * 1000, NOW), true)
87
+ assert.equal(unarchiveGraceActive(NOW - UNARCHIVE_GRACE_MS, NOW), false)
88
+ assert.equal(unarchiveGraceActive(0, NOW), false)
89
+ assert.equal(unarchiveGraceActive(undefined, NOW), false)
90
+ assert.equal(unarchiveGraceActive(Number.NaN, NOW), false)
91
+ })
92
+
93
+ test('全局宽限:常量与天级时长的量级自洽', () => {
94
+ assert.equal(UNARCHIVE_GRACE_MS, 10 * 60 * 1000)
95
+ })
96
+
83
97
  test('updatedAt 取创建时间与最近活跃的较大者', () => {
84
98
  assert.equal(updatedAtOf({ createdAt: 100 }, 50), 100)
85
99
  assert.equal(updatedAtOf({ createdAt: 100 }, 500), 500)
@@ -76,7 +76,7 @@ function makeDomain(initialIds) {
76
76
  // 台账域句柄;openRejected 模拟台账域打开失败
77
77
  function makeLedgerDomain(entries) {
78
78
  const domain = {
79
- state: { deleted: entries },
79
+ state: { deleted: entries, unarchivedAt: 0 },
80
80
  writes: 0,
81
81
  global: {
82
82
  get: () => domain.state,
@@ -118,16 +118,20 @@ function makeCtx({
118
118
  openRejected,
119
119
  workspaces,
120
120
  settingsValue,
121
+ settingsService: settingsServiceOverride,
121
122
  timerAvailable,
122
123
  readSessions,
124
+ configRef,
125
+ configEditor,
123
126
  }) {
124
127
  const routes = []
125
128
  const eventHandlers = {}
126
129
  const pendingInjects = []
130
+ const disposers = []
127
131
  const ledgerDomain = ledger === undefined ? makeLedgerDomain([]) : ledger
128
132
  const workspaceList = workspaces || []
129
133
  const settingsState = { value: settingsValue }
130
- const settingsService = {
134
+ const settingsService = settingsServiceOverride ?? {
131
135
  get: () => settingsState.value,
132
136
  register: () => ({ resolved: undefined }),
133
137
  // 宿主 update 为异步串行:合并进微任务队列后生效,读旧值发生在 flush 前
@@ -173,7 +177,12 @@ function makeCtx({
173
177
  const base = {
174
178
  effect: (fn) => fn(),
175
179
  inject: (_deps, fn) => { pendingInjects.push(fn) },
176
- get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
180
+ // fiber 桩:0.1.7 configure 装配以本条目 fiber 为 owner
181
+ fiber: { entry: { options: { id: 'session-manager', name: '@mzzsfy/dsh-session-manager' } } },
182
+ get: (name) => {
183
+ if (name === 'configEditor') return configEditor
184
+ return { agents, sessionPersistence, settings: settingsService }[name]
185
+ },
177
186
  on: (event, handler) => { eventHandlers[event] = handler },
178
187
  // 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
179
188
  logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
@@ -188,7 +197,7 @@ function makeCtx({
188
197
  return services[prop]
189
198
  },
190
199
  })
191
- apply(ctx, undefined)
200
+ apply(ctx, configRef)
192
201
  return {
193
202
  handlers: new Map(routes.map((route) => [route.path, route.handler])),
194
203
  eventHandlers,
@@ -199,7 +208,15 @@ function makeCtx({
199
208
  readCounts,
200
209
  // 模拟宿主 settings 与 timer 服务激活:触发 inject 回调(注册 + 启动补扫 + 周期武装)
201
210
  activateSettings: () => {
202
- const injected = { settings: settingsService, interval: timerAvailable === false ? undefined : intervalStub }
211
+ const injected = {
212
+ settings: settingsService,
213
+ interval: timerAvailable === false ? undefined : intervalStub,
214
+ // 注入上下文的 effect 桩:0.1.7 分支经它注册 configure 装配
215
+ effect(fn) {
216
+ const disposer = fn()
217
+ if (typeof disposer === 'function') disposers.push(disposer)
218
+ },
219
+ }
203
220
  while (pendingInjects.length > 0) pendingInjects.shift()(injected)
204
221
  },
205
222
  }
@@ -464,6 +481,40 @@ test('取消归档路由:快照领先域时补全清理(重试路径)', skipMiss
464
481
  assert.deepEqual(registry.state.archivedSessionIds, [])
465
482
  })
466
483
 
484
+ test('取消归档宽限:恢复后 graceMs 内同进程评估暂停,不秒归档', skipMissingDeps, async () => {
485
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-grace-'))
486
+ try {
487
+ const artifact = path.join(dir, 's1.jsonl')
488
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
489
+ const DAY_MS = 24 * 60 * 60 * 1000
490
+ const stale = Date.now() - 30 * DAY_MS
491
+ await utimes(artifact, stale / 1000, stale / 1000)
492
+ const headers = [{ id: 's1', cwd: 'C:\\x', createdAt: stale }]
493
+ const ctxBundle = makeCtx({
494
+ archivedIds: [],
495
+ headers,
496
+ agents: new Map(),
497
+ sessionPersistence: { locate: () => ({ path: artifact }) },
498
+ domain: makeDomain([]),
499
+ })
500
+ const { activateSettings, registry, handlers, eventHandlers } = ctxBundle
501
+ activateSettings()
502
+ await waitFor(() => registry.archiveCalls.length > 0)
503
+ assert.deepEqual(registry.archiveCalls, ['s1'])
504
+ // 手动恢复:归档集合移除 + 进程内宽限生效
505
+ const res = response()
506
+ await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
507
+ assert.equal(res.status, 200)
508
+ assert.deepEqual(registry.state.archivedSessionIds, [])
509
+ // 宽限内经新会话创建触发评估:同产物仍超期,但不再归档
510
+ eventHandlers['session/created']({ header: { cwd: 'C:\\x' } })
511
+ await new Promise((resolve) => setTimeout(resolve, 80))
512
+ assert.deepEqual(registry.archiveCalls, ['s1'], '宽限期内不得重新归档')
513
+ } finally {
514
+ await rm(dir, { recursive: true, force: true })
515
+ }
516
+ })
517
+
467
518
  test('info 路由:会话不存在拒绝', skipMissingDeps, async () => {
468
519
  const { handlers } = makeCtx({ archivedIds: [], headers: [], agents: new Map() })
469
520
  const res = response()
@@ -812,6 +863,42 @@ test('周期评估:间隔设置运行时变更经 tick 对账(0 重启用 / 关
812
863
  }
813
864
  })
814
865
 
866
+ test('settings 方法面缺失(0.1.7 SettingsForms 形态):tick 与 status 路由不抛错,配置回落默认', skipMissingDeps, async () => {
867
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-methodface-'))
868
+ // 宿主 0.1.7-alpha.1 将 settings 服务换形为 SettingsForms:缺 get/register,仅剩 update。
869
+ // 旧形态直接 settings.get 会在 timer 回调抛 TypeError 崩进程(实测事故),守卫后应回落默认值
870
+ const DAY_MS = 24 * 60 * 60 * 1000
871
+ const TICK_MS = 60 * 1000
872
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
873
+ try {
874
+ const artifact = path.join(dir, 'a.jsonl')
875
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
876
+ const stale = Date.now() - 30 * DAY_MS
877
+ await utimes(artifact, stale / 1000, stale / 1000)
878
+ const settingsForms = { update: async () => {} }
879
+ const { activateSettings, handlers, registry } = makeCtx({
880
+ archivedIds: [],
881
+ headers: [{ id: 'a', cwd: 'C:\\x', createdAt: stale }],
882
+ agents: new Map(),
883
+ settingsService: settingsForms,
884
+ sessionPersistence: { locate: () => ({ path: artifact }) },
885
+ })
886
+ activateSettings()
887
+ // 守卫生效:配置读不到回落默认(days=7,intervalHours=24),30 天超期夹具到期即按默认轮归档
888
+ mock.timers.tick(DAY_MS + TICK_MS)
889
+ await waitFor(() => registry.archiveCalls.includes('a'))
890
+ assert.deepEqual(registry.archiveCalls, ['a'], '配置缺失按默认间隔正常评估,不因服务形变停摆')
891
+ // status 路由读设置不抛:回默认 days/intervalHours
892
+ const statusHandler = handlers.get('/api/session-manager/status')
893
+ const res = response()
894
+ await statusHandler(request('s1', 'GET'), res)
895
+ assert.equal(res.status, 200)
896
+ } finally {
897
+ mock.timers.reset()
898
+ await rm(dir, { recursive: true, force: true })
899
+ }
900
+ })
901
+
815
902
  test('周期评估:缩短间隔经 earliest 钳制前移,不等满旧周期', skipMissingDeps, async () => {
816
903
  const dir = await mkdtemp(path.join(tmpdir(), 'sm-shorten-'))
817
904
  const TICK_MS = 60 * 1000
@@ -1728,3 +1815,103 @@ test('自动归档配置提交判定:client 与 core 镜像同源,提交守卫
1728
1815
  assert.match(clientSource, /classifyAutoArchiveInput\(committed\[field\], text\)/, 'commit 必须以 committed[field] 为守卫基准')
1729
1816
  })
1730
1817
 
1818
+
1819
+ // --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
1820
+
1821
+ test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', () => {
1822
+ // Given 静态 Config 导出(0.1.7 节表单事实源)
1823
+ assert.ok(indexModule.Config, 'Config 导出必须在场')
1824
+ // When 空配置校验
1825
+ const resolved = indexModule.Config['~standard'].validate({})
1826
+ // Then 产整节单 ref(get 协议),字段为 schema 默认值
1827
+ assert.equal(resolved.issues, undefined)
1828
+ const section = resolved.value.get()
1829
+ assert.equal(section.autoArchiveDays, DEFAULT_AUTO_ARCHIVE_DAYS)
1830
+ assert.equal(section.autoArchiveIntervalHours, DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
1831
+ // When 显式值校验
1832
+ // Then 值透传
1833
+ const provided = indexModule.Config['~standard'].validate({ autoArchiveDays: 3 }).value.get()
1834
+ assert.equal(provided.autoArchiveDays, 3)
1835
+ })
1836
+
1837
+ test('0.1.7 形态:settings 无 get/register 时读回落 config ref 节值', skipMissingDeps, async () => {
1838
+ // Given settingsForms 只有 update(0.1.7 真实方法面)+ config ref 携带用户值
1839
+ const settingsForms = { update: async () => {} }
1840
+ const configRef = { get: () => ({ autoArchiveDays: 3, autoArchiveIntervalHours: 12 }) }
1841
+ const { handlers } = makeCtx({
1842
+ archivedIds: [],
1843
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1844
+ agents: new Map(),
1845
+ settingsService: settingsForms,
1846
+ configRef,
1847
+ })
1848
+ // When GET auto-archive
1849
+ const res = response()
1850
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), res)
1851
+ // Then 返回 config 节值而非默认
1852
+ assert.deepEqual(res.body, { days: 3, intervalHours: 12 })
1853
+ })
1854
+
1855
+ test('0.1.7 形态:auto-archive POST 经 configEditor.edit 落 fiber.entry 并回显新值', skipMissingDeps, async () => {
1856
+ // Given settingsForms 只有 update + configEditor 桩(合并写回 configStore)
1857
+ const settingsForms = { update: async () => {} }
1858
+ const configStore = { autoArchiveDays: 7 }
1859
+ const editCalls = []
1860
+ const configRef = { get: () => ({ ...configStore }) }
1861
+ const { handlers } = makeCtx({
1862
+ archivedIds: [],
1863
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1864
+ agents: new Map(),
1865
+ settingsService: settingsForms,
1866
+ configRef,
1867
+ configEditor: {
1868
+ async edit(entry, change) {
1869
+ editCalls.push(entry)
1870
+ const next = change({ ...configStore }, {})
1871
+ for (const [key, value] of Object.entries(next)) configStore[key] = value
1872
+ },
1873
+ },
1874
+ })
1875
+ // When POST 写 days
1876
+ const changed = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1877
+ // Then edit 定位本条目,合并写回,响应回显新值
1878
+ assert.deepEqual(changed.body, { days: 3, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS })
1879
+ assert.equal(editCalls.length, 1)
1880
+ assert.equal(editCalls[0].options.id, 'session-manager')
1881
+ assert.equal(configStore.autoArchiveDays, 3)
1882
+ })
1883
+
1884
+ test('0.1.7 形态:configEditor 缺失即 400 拒写,配置不变', skipMissingDeps, async () => {
1885
+ // Given settingsForms 只有 update 且无 configEditor
1886
+ const settingsForms = { update: async () => {} }
1887
+ const configRef = { get: () => ({}) }
1888
+ const { handlers } = makeCtx({
1889
+ archivedIds: [],
1890
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1891
+ agents: new Map(),
1892
+ settingsService: settingsForms,
1893
+ configRef,
1894
+ })
1895
+ // When POST 写 days
1896
+ const denied = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1897
+ // Then 400 错误响应
1898
+ assert.equal(denied.status, 400)
1899
+ assert.ok(denied.body.error)
1900
+ })
1901
+
1902
+ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissingDeps, () => {
1903
+ // Given settingsForms 带 configure 无 register
1904
+ const configureCalls = []
1905
+ const settingsForms = { configure: (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} } }
1906
+ const { activateSettings } = makeCtx({
1907
+ archivedIds: [],
1908
+ headers: [],
1909
+ agents: new Map(),
1910
+ settingsService: settingsForms,
1911
+ })
1912
+ // When 激活 settings 注入
1913
+ activateSettings()
1914
+ // Then 关闭自动页
1915
+ assert.equal(configureCalls.length, 1)
1916
+ assert.deepEqual(configureCalls[0].presentation, { auto: false })
1917
+ })