@mzzsfy/dsh-session-manager 0.9.6 → 0.9.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.9.6",
4
+ "version": "0.9.7",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -20,10 +20,12 @@
20
20
  ]
21
21
  },
22
22
  "compatibility": {
23
+ "hostMin": "0.2.1-rc.2",
23
24
  "dshReleases": {
24
25
  "0.2.0-rc.1": "unknown",
25
- "0.2.0-rc.2": "compatible",
26
- "0.2.1-alpha.1": "unknown"
26
+ "0.2.0-rc.2": "unknown",
27
+ "0.2.1-alpha.1": "unknown",
28
+ "0.2.1-rc.2": "compatible"
27
29
  }
28
30
  }
29
31
  },
package/src/index.js CHANGED
@@ -37,7 +37,6 @@ export const name = 'dsh-session-manager'
37
37
  export const inject = ['webServer', 'workspaceRegistry', 'sessionQuery', 'storageDomain', 'agents', 'sessions', 'sessionPersistence']
38
38
 
39
39
  const NAMESPACE = 'session-manager'
40
- const WORKSPACE_DOMAIN_NAME = 'workspace'
41
40
 
42
41
  // 已删除台账域:global 单列表,条目为回收站还原后重挂载所需的最小信息;
43
42
  // heldPath 仅回收区降级条目携带(暂存路径,重挂载时移回 path 原位置)
@@ -226,9 +225,9 @@ export function apply(ctx, config) {
226
225
  const inFlightDeletes = new Set()
227
226
 
228
227
  // 插件回收区:系统回收站不可用环境的删除降级暂存地,重挂载时移回原位置。
229
- // 测试经 env DSH_QUARANTINE_DIR 注入临时目录
228
+ // 测试经 env DSH_QUARANTINE_DIR 注入临时目录;缺省从宿主 home 派生(DSH_HOME 即 .dsh 根)
230
229
  const quarantineDir = process.env.DSH_QUARANTINE_DIR
231
- || join(homedir(), '.dsh', 'trash', 'session-manager')
230
+ || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'trash', 'session-manager')
232
231
 
233
232
  // header 查找缓存:归档/删除/重挂载路由共用(inputs 浮层已迁 dsh-context-manager)
234
233
  const headerCache = new Map()
@@ -332,27 +331,11 @@ export function apply(ctx, config) {
332
331
  evaluateArchives(cwd)
333
332
  })
334
333
 
335
- // 取消归档:直写 workspace 域 global,无官方 API 可用。注册表把域 global 快照到
336
- // 进程内 state 且只在自身写路径同步,直写后必须把快照一并改掉:否则注册表后续
337
- // 全量写回(含自动归档)与 workspace.list 重连基线都会复活该 id,官方归档的幂等
338
- // 判定也会因旧快照静默跳过。合并 durable 与快照再移除,可自愈历史失同步与清理
339
- // 半失败(经取消归档重试即补全),代价是快照中的陈旧 id 会随任一次清理重新落盘,
340
- // 可经再次取消归档清除。inject 保证注册表就绪,快照缺失即上游形态变更,loud fail;
341
- // 域 global 仅支持整体写回,读改写窗口若与注册表两阶段变更交错,理论上可回退其
342
- // pendingMutation/workspaceIds 中间态,下次启动 validateStoredState 将 loud fail,
343
- // 窗口极窄且域 API 无原子原语可用,属已知取舍(详见 README)。
334
+ // 取消归档:官方 workspaceRegistry.unarchiveSession(0.2.1-rc.2+ 串行实现,
335
+ // 未归档 id 幂等不写)写穿 state,无私有直写的读改写窗口与快照失同步问题;
336
+ // 合并自愈语义随私有写一并退役(开发守则:移除被宿主能力取代的实现)
344
337
  async function removeArchivedId(sessionId) {
345
- const domain = ctx.storageDomain.get(WORKSPACE_DOMAIN_NAME)
346
- if (domain === undefined) throw new Error('workspace 域未打开')
347
- const durable = domain.global.get()
348
- const cached = ctx.workspaceRegistry.state
349
- if (cached === undefined) throw new Error('workspace 注册表快照不可用')
350
- const merged = new Set(durable.archivedSessionIds.map(String))
351
- for (const id of cached.archivedSessionIds) merged.add(String(id))
352
- if (!merged.delete(String(sessionId))) return
353
- const next = { ...durable, archivedSessionIds: [...merged] }
354
- await domain.global.set(next)
355
- ctx.workspaceRegistry.state = next
338
+ await ctx.workspaceRegistry.unarchiveSession(sessionId)
356
339
  }
357
340
 
358
341
  // 台账记账:trash 成功即记录,路径为回收站「原位置」还原所需。
@@ -39,17 +39,26 @@ const GHOST_DIR = path.join(tmpdir(), 'sm-ghost-fixture', 's1')
39
39
  const GHOST_PATH = path.join(GHOST_DIR, 'session.jsonl.zstd')
40
40
 
41
41
  function makeRegistry(initialIds) {
42
- // 真实注册表契约:archivedSessionIds 是进程内快照的 getter,官方读路径全部经快照;
43
- // archiveSession 归档即入集合(官方幂等判定依据),桩保持同语义
42
+ // 真实注册表契约(0.2.1-rc.2+):archivedSessionIds 是进程内快照的 getter;
43
+ // archiveSession 归档即入集合,unarchiveSession 串行移除(未归档 id 幂等不写),
44
+ // 两个官方入口都写穿 state——快照与持久集由注册表自身保证一致
44
45
  const registry = {
45
46
  state: { initialized: true, workspaceIds: [], archivedSessionIds: initialIds },
46
47
  list: () => [],
47
48
  archiveCalls: [],
49
+ unarchiveCalls: [],
48
50
  archiveSession: async (id) => {
49
51
  const sid = String(id)
50
52
  registry.archiveCalls.push(sid)
51
53
  registry.state.archivedSessionIds = [...registry.state.archivedSessionIds, sid]
52
54
  },
55
+ unarchiveSession: async (id) => {
56
+ const sid = String(id)
57
+ const next = registry.state.archivedSessionIds.filter((existing) => String(existing) !== sid)
58
+ if (next.length === registry.state.archivedSessionIds.length) return
59
+ registry.unarchiveCalls.push(sid)
60
+ registry.state.archivedSessionIds = next
61
+ },
53
62
  }
54
63
  Object.defineProperty(registry, 'archivedSessionIds', {
55
64
  get: () => registry.state.archivedSessionIds,
@@ -399,17 +408,17 @@ test('多工作区 detach:首个失败不终止后续,全部失败进日志', sk
399
408
  }
400
409
  })
401
410
 
402
- test('取消归档:域领先快照时反向合并清理', skipMissingDeps, async () => {
403
- const domain = makeDomain(['s1'])
404
- const { handlers, registry } = makeCtx({ archivedIds: [], headers: [HEADER], agents: new Map(), domain })
411
+ test('取消归档路由:走官方 unarchiveSession 写穿注册表', skipMissingDeps, async () => {
412
+ const domain = makeDomain(['s1', 's2'])
413
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1', 's2'], headers: [HEADER], agents: new Map(), domain })
405
414
  const res = response()
406
415
  await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
407
416
  assert.equal(res.status, 200)
408
417
  assert.deepEqual(res.body, { ok: true })
409
- // durable 有而快照无:合并后仍完成移除,防漏清理
410
- assert.deepEqual(domain.state.archivedSessionIds, [])
411
- assert.equal(domain.writes, 1)
412
- assert.deepEqual(registry.state.archivedSessionIds, [])
418
+ // 官方 API 写穿:注册表快照移除,无私有直写路径
419
+ assert.deepEqual(registry.unarchiveCalls, ['s1'])
420
+ assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
421
+ assert.deepEqual(registry.archivedSessionIds, ['s2'])
413
422
  })
414
423
 
415
424
  test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissingDeps, async () => {
@@ -435,52 +444,16 @@ test('台账 deletedAt 为删除时刻的数值(面板排序依据)', skipMissin
435
444
  }
436
445
  })
437
446
 
438
- test('取消归档路由:从域集合移除目标 id 并写回', skipMissingDeps, async () => {
439
- const domain = makeDomain(['s1', 's2'])
440
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
441
- const res = response()
442
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
443
- assert.equal(res.status, 200)
444
- assert.deepEqual(res.body, { ok: true })
445
- assert.deepEqual(domain.state.archivedSessionIds, ['s2'])
446
- assert.equal(domain.writes, 1)
447
- // 复活守卫:注册表进程内快照必须同步移除,否则官方全量写回与 list 基线会恢复该 id
448
- assert.deepEqual(registry.state.archivedSessionIds, ['s2'])
449
- assert.deepEqual(registry.archivedSessionIds, ['s2'])
450
- })
451
-
452
- test('取消归档路由:workspace 域未打开时拒绝', skipMissingDeps, async () => {
453
- const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain: null })
454
- const res = response()
455
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
456
- assert.equal(res.status, 400)
457
- assert.equal(res.body.error, 'workspace 域未打开')
458
- })
459
-
460
- test('取消归档路由:未归档 id 幂等跳过,不产生写回', skipMissingDeps, async () => {
461
- const domain = makeDomain(['s1'])
462
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
447
+ test('取消归档路由:未归档 id 幂等,注册表零写', skipMissingDeps, async () => {
448
+ const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
463
449
  const res = response()
464
450
  await handlers.get('/api/session-manager/unarchive')(request('s9'), res)
465
451
  assert.equal(res.status, 200)
466
452
  assert.deepEqual(res.body, { ok: true })
467
- assert.deepEqual(domain.state.archivedSessionIds, ['s1'])
468
- assert.equal(domain.writes, 0)
453
+ assert.deepEqual(registry.unarchiveCalls, [])
469
454
  assert.deepEqual(registry.state.archivedSessionIds, ['s1'])
470
455
  })
471
456
 
472
- test('取消归档路由:快照领先域时补全清理(重试路径)', skipMissingDeps, async () => {
473
- const domain = makeDomain([])
474
- const { handlers, registry } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map(), domain })
475
- const res = response()
476
- await handlers.get('/api/session-manager/unarchive')(request('s1'), res)
477
- assert.equal(res.status, 200)
478
- assert.deepEqual(res.body, { ok: true })
479
- assert.deepEqual(domain.state.archivedSessionIds, [])
480
- assert.equal(domain.writes, 1)
481
- assert.deepEqual(registry.state.archivedSessionIds, [])
482
- })
483
-
484
457
  test('取消归档宽限:恢复后 graceMs 内同进程评估暂停,不秒归档', skipMissingDeps, async () => {
485
458
  const dir = await mkdtemp(path.join(tmpdir(), 'sm-grace-'))
486
459
  try {
@@ -1077,7 +1050,7 @@ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMi
1077
1050
  try {
1078
1051
  const domain = makeDomain(['s1'])
1079
1052
  const workspace = makeWorkspace('C:\\x', ['s1'])
1080
- const { handlers, ledger } = makeCtx({
1053
+ const { handlers, ledger, registry } = makeCtx({
1081
1054
  archivedIds: ['s1'],
1082
1055
  headers: [HEADER],
1083
1056
  agents: IDLE_S1,
@@ -1097,7 +1070,8 @@ test('删除成功:trash 目标为会话目录,台账记录目录路径', skipMi
1097
1070
  assert.equal(ledger.state.deleted.length, 1)
1098
1071
  assert.equal(ledger.state.deleted[0].sessionId, 's1')
1099
1072
  assert.equal(ledger.state.deleted[0].path, artifact.locatedDir)
1100
- assert.deepEqual(domain.state.archivedSessionIds, [])
1073
+ // 取消归档走官方 unarchiveSession:注册表快照为事实源,域不再被插件直写
1074
+ assert.deepEqual(registry.state.archivedSessionIds, [])
1101
1075
  assert.deepEqual(workspace.sessionIds, [])
1102
1076
  } finally {
1103
1077
  await artifact.cleanup()
@@ -1251,17 +1225,18 @@ test('删除:产物已缺失时运行中守卫仍然生效', skipMissingDeps, as
1251
1225
  assert.equal(res.body.error, MESSAGES.running)
1252
1226
  })
1253
1227
 
1254
- test('删除:产物已缺失且清理半失败时聚合失败点', skipMissingDeps, async () => {
1228
+ test('删除:产物已缺失且归档清理半失败时聚合失败点', skipMissingDeps, async () => {
1255
1229
  const workspace = makeWorkspace('C:\\x', ['s1'], { detachError: new Error('detach boom') })
1256
- const { handlers, logger } = makeCtx({
1257
- archivedIds: [],
1230
+ const { handlers, logger, registry } = makeCtx({
1231
+ archivedIds: ['s1'],
1258
1232
  headers: [HEADER],
1259
1233
  agents: IDLE_S1,
1260
- domain: null,
1261
1234
  sessionPersistence: { locate: (header) => header.id === 's1' ? { path: GHOST_PATH } : undefined },
1262
1235
  ledger: makeLedgerDomain([{ sessionId: 's1', path: GHOST_DIR, deletedAt: 1 }]),
1263
1236
  workspaces: [workspace],
1264
1237
  })
1238
+ // 官方取消归档半失败:注入 unarchiveSession 抛错
1239
+ registry.unarchiveSession = async () => { throw new Error('unarchive boom') }
1265
1240
  const res = response()
1266
1241
  await withTrashStub(async () => {}, async () => {
1267
1242
  await handlers.get('/api/session-manager/delete')(request('s1'), res)
@@ -1323,16 +1298,16 @@ test('归档评估:超期运行中会话零产物 IO(早退,core 过滤为第二
1323
1298
  assert.deepEqual(registry.archiveCalls, [])
1324
1299
  })
1325
1300
 
1326
- test('删除:workspace 域未打开时归档清理失败,响应 partial 且台账已记录', skipMissingDeps, async () => {
1301
+ test('删除:取消归档半失败时响应 partial 且台账已记录', skipMissingDeps, async () => {
1327
1302
  const artifact = await makeLocatedArtifact()
1328
1303
  try {
1329
- const { handlers, ledger } = makeCtx({
1304
+ const { handlers, ledger, registry } = makeCtx({
1330
1305
  archivedIds: ['s1'],
1331
1306
  headers: [HEADER],
1332
1307
  agents: IDLE_S1,
1333
- domain: null,
1334
1308
  sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1335
1309
  })
1310
+ registry.unarchiveSession = async () => { throw new Error('unarchive boom') }
1336
1311
  const res = response()
1337
1312
  await withTrashStub(async () => {}, async () => {
1338
1313
  await handlers.get('/api/session-manager/delete')(request('s1'), res)
@@ -1383,12 +1358,10 @@ test('删除:detach 与台账同时失败时,partial 消息聚合两个失败点
1383
1358
  test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDeps, async () => {
1384
1359
  const artifact = await makeLocatedArtifact()
1385
1360
  try {
1386
- const domain = makeDomain(['s1'])
1387
- const { handlers } = makeCtx({
1361
+ const { handlers, registry } = makeCtx({
1388
1362
  archivedIds: ['s1'],
1389
1363
  headers: [HEADER],
1390
1364
  agents: IDLE_S1,
1391
- domain,
1392
1365
  sessionPersistence: { locate: (header) => header.id === 's1' ? { path: artifact.locatedPath } : undefined },
1393
1366
  openRejected: new Error('ledger down'),
1394
1367
  })
@@ -1399,8 +1372,8 @@ test('台账域打开失败:删除其余成功仍响应 partial', skipMissingDep
1399
1372
  assert.equal(res.status, 200)
1400
1373
  assert.equal(res.body.partial, true)
1401
1374
  assert.equal(res.body.message, '已移入回收站,但重挂载记录失败')
1402
- // 台账失败不阻断删除主链路:归档清理照常完成
1403
- assert.deepEqual(domain.state.archivedSessionIds, [])
1375
+ // 台账失败不阻断删除主链路:归档清理经官方 API 照常完成
1376
+ assert.deepEqual(registry.state.archivedSessionIds, [])
1404
1377
  } finally {
1405
1378
  await artifact.cleanup()
1406
1379
  }