@gehennawu/dsh-service 1.1.2 → 1.3.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/index.js CHANGED
@@ -15,6 +15,8 @@ import { promisify } from 'node:util'
15
15
  import { ServerResponse as NodeServerResponse } from 'node:http'
16
16
  import z from '@deepseek-ai/schemastery'
17
17
  import { createBackupIntegrity } from './backup-integrity.js'
18
+ import { collectPluginCompat, pluginCompatCheckItem } from './plugin-compat.js'
19
+ import { collectPluginHealth, pluginCheckItem, restartPluginEntry } from './plugin-health.js'
18
20
  import {
19
21
  buildCliproxyAccountPlan as buildCliproxyAccountPlanAdapter,
20
22
  cliproxyFetchGuard as cliproxyFetchGuardAdapter,
@@ -131,6 +133,16 @@ const SESSIONS_BYTES_MAX_IDS = 200
131
133
  const SESSIONS_BYTES_MAX_ENTRIES = 1000
132
134
  // 详情快照缓存(v0.36 查看渲染优化):live 会话 30s 后重读保新鲜,冷会话长期复用至槽位失效/删除。
133
135
  const SESSIONS_VIEW_LIVE_TTL_MS = 30 * 1000
136
+ // 会话标题缓存(v1.1.3 列表加载优化):0.1.2-alpha.3 的 readTitleSnapshots 经 SessionCorpus.
137
+ // projectMany → persistence.inspect 对每个冷会话全量解析整份 JSONL 日志且零官方缓存——会话一多,
138
+ // 每次 sessions-list 都是整库日志重读,远贵于体积 stat。标题按 revision(stat 指纹,跨重启稳定)
139
+ // 键控缓存:冷会话文件不变标题不变,只对新增/revision 变更/live 超时条目重读;缓存持久化到
140
+ // DSH_HOME(仅标题+revision,不含内容),宿主重启后首个列表加载同样命中。
141
+ const SESSIONS_TITLE_FILE = 'dsh-service-session-titles.json'
142
+ const SESSIONS_TITLE_VERSION = 1
143
+ const SESSIONS_TITLE_MAX_ENTRIES = 2000
144
+ const SESSIONS_TITLE_LIVE_TTL_MS = 30 * 1000
145
+ const SESSIONS_TITLE_COLD_TTL_MS = 5 * 60 * 1000
134
146
  // 详情/检索里视为「机制性噪声」的事件类型:折叠展示计数,用户可展开。
135
147
  const SESSION_NOISE_TYPES = new Set([
136
148
  'turn/start', 'step/start', 'step/end', 'assistant/chunk', 'request/header',
@@ -2253,11 +2265,42 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2253
2265
  const nodeMajor = parseInt(process.versions.node, 10)
2254
2266
  add('node-version', Number.isFinite(nodeMajor) && nodeMajor >= requiredNodeMajor ? 'ok' : 'warning', `${process.version}:${requiredNodeMajor}`)
2255
2267
 
2268
+ // v1.3 插件健康检查:只检查异常——loader 缺席降级为 info 检查项、不带异常行数组;
2269
+ // 停用条目(无论内置/自定义)一律不算异常,正常插件不下发(官方设置页已有完整清单与开关)。
2270
+ let pluginReport
2271
+ try {
2272
+ pluginReport = await collectPluginHealth(ctx)
2273
+ } catch (error) {
2274
+ pluginReport = { available: false, total: 0, issues: [] }
2275
+ }
2276
+ if (!pluginReport.available) add('plugins', 'info', 'unavailable')
2277
+ else checks.push(pluginCheckItem(pluginReport))
2278
+
2279
+ // v1.3 插件兼容性:对照已核实的 alpha 破坏面清单(client-runtime 供应商移除、SQLite
2280
+ // persistence 移除、聊天/统计条 CSS 哈希漂移、data-time-hover-root 删除)扫描启用插件的
2281
+ // 清单与入口代码;loader 缺席降级为 info 检查项、不带扫描结果。
2282
+ let compatReport
2283
+ try {
2284
+ compatReport = await collectPluginCompat(ctx)
2285
+ } catch (error) {
2286
+ compatReport = { available: false, scanned: 0, issues: [], declaredOnly: [], unknown: [] }
2287
+ }
2288
+ if (!compatReport.available) add('plugin-compat', 'info', 'unavailable')
2289
+ else checks.push(pluginCompatCheckItem(compatReport))
2290
+
2256
2291
  let status = 'ok'
2257
2292
  // advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
2258
2293
  if (checks.some((check) => check.status === 'error')) status = 'error'
2259
2294
  else if (checks.some((check) => check.status === 'warning' && check.advisory !== true)) status = 'warning'
2260
- return { status, checkedAt: Date.now(), checks }
2295
+ return {
2296
+ status,
2297
+ checkedAt: Date.now(),
2298
+ checks,
2299
+ ...(pluginReport.available ? { pluginIssues: pluginReport.issues } : {}),
2300
+ ...(compatReport.available
2301
+ ? { pluginCompat: { scanned: compatReport.scanned, issues: compatReport.issues, declaredOnly: compatReport.declaredOnly, unknown: compatReport.unknown } }
2302
+ : {}),
2303
+ }
2261
2304
  }
2262
2305
 
2263
2306
  async function collectHealth(ctx) {
@@ -3256,6 +3299,46 @@ async function saveDeletedSessions(dshHome, data) {
3256
3299
  }
3257
3300
  }
3258
3301
 
3302
+ // 标题缓存磁盘持久化:{version, items: {id: {title, revision?, live}}}——只存标题与指纹,
3303
+ // 不含任何会话内容;损坏/版本不符整体丢弃(回退全量拉取,宁慢勿错)。at 不落盘:
3304
+ // 加载后按 0 处理,revision 对不上或缺失时自然判定过期重读。
3305
+ function createEmptySessionTitles() {
3306
+ return { version: SESSIONS_TITLE_VERSION, items: {} }
3307
+ }
3308
+
3309
+ async function loadSessionTitles(dshHome) {
3310
+ try {
3311
+ const parsed = JSON.parse(await readFile(join(dshHome, SESSIONS_TITLE_FILE), 'utf8'))
3312
+ if (parsed?.version !== SESSIONS_TITLE_VERSION || typeof parsed.items !== 'object' || parsed.items === null) return new Map()
3313
+ const entries = new Map()
3314
+ for (const [id, entry] of Object.entries(parsed.items)) {
3315
+ if (typeof id !== 'string' || id === '') continue
3316
+ if (typeof entry?.title !== 'string' || typeof entry.revision !== 'string') continue
3317
+ entries.set(id, { title: entry.title, revision: entry.revision, live: entry.live === true, at: 0 })
3318
+ }
3319
+ return entries
3320
+ } catch (_) {
3321
+ return new Map()
3322
+ }
3323
+ }
3324
+
3325
+ async function saveSessionTitles(dshHome, cache) {
3326
+ const items = {}
3327
+ for (const [id, entry] of cache) {
3328
+ if (typeof entry?.title !== 'string') continue
3329
+ items[id] = { title: entry.title, ...(typeof entry.revision === 'string' ? { revision: entry.revision } : {}), live: entry.live === true }
3330
+ }
3331
+ await mkdir(dshHome, { recursive: true })
3332
+ const target = join(dshHome, SESSIONS_TITLE_FILE)
3333
+ const temporary = `${target}.${randomUUID()}.tmp`
3334
+ try {
3335
+ await writeFile(temporary, JSON.stringify({ version: SESSIONS_TITLE_VERSION, items }), { mode: 0o600 })
3336
+ await rename(temporary, target)
3337
+ } finally {
3338
+ await rm(temporary, { force: true })
3339
+ }
3340
+ }
3341
+
3259
3342
  /** 单个会话的事件文本(与官方 extractSessionEventText 语义一致,不依赖官方内部包)。 */
3260
3343
  function sessionEventText(event) {
3261
3344
  if (typeof event !== 'object' || event === null) return ''
@@ -3320,9 +3403,26 @@ async function sessionDirectoryBytes(ctx, header) {
3320
3403
  }
3321
3404
  }
3322
3405
 
3406
+ // 标题缓存条目是否仍新鲜:有 revision 源(sessionPersistence.listSnapshots 可用)时按指纹
3407
+ // 精确判定——文件没变标题就不变,冷会话永久命中;快照清单里缺席的 id(纯内存 live 会话)
3408
+ // 及 revision 源不可用时按 TTL 兜底。宁重读不展示旧标题。
3409
+ function sessionTitleFresh(entry, record, revisions, now) {
3410
+ if (entry === undefined) return false
3411
+ if (revisions !== null) {
3412
+ const revision = revisions.get(record.header.id)
3413
+ if (revision !== undefined) return entry.revision === revision
3414
+ return now - entry.at < SESSIONS_TITLE_LIVE_TTL_MS
3415
+ }
3416
+ const ttl = record.live === true ? SESSIONS_TITLE_LIVE_TTL_MS : SESSIONS_TITLE_COLD_TTL_MS
3417
+ return now - entry.at < ttl
3418
+ }
3419
+
3323
3420
  // 会话管理列表:live + 冷会话合并,标注归档/已删除,补标题。列表不携带体积——
3324
3421
  // 体积走 sessions-bytes 懒加载(v0.36:全量下发意味着打开/切换就要逐个 readdir+stat)。
3325
- async function listSessionsForManage(ctx, dshHome, scope = 'all') {
3422
+ // v1.1.3:readTitleSnapshots 对每个冷会话全量解析整份日志(官方零缓存),改为 revision
3423
+ // 键控缓存——列表加载只对新增/变更条目重读,重复加载/面板重开零整库扫描;listing 与
3424
+ // 已删除清单并行获取。
3425
+ async function listSessionsForManage(ctx, dshHome, scope = 'all', titleCache = null) {
3326
3426
  const sessionQuery = ctx.get('sessionQuery')
3327
3427
  const workspaceRegistry = ctx.get('workspaceRegistry')
3328
3428
  const available = sessionQuery !== undefined && typeof sessionQuery.listSessions === 'function'
@@ -3332,27 +3432,72 @@ async function listSessionsForManage(ctx, dshHome, scope = 'all') {
3332
3432
  if (scope === 'deleted') {
3333
3433
  return { available: true, items: [], archivedIds: [], deleted: (await loadDeletedSessions(dshHome)).items }
3334
3434
  }
3335
- const records = await sessionQuery.listSessions()
3435
+ const now = Date.now()
3436
+ // revision 源:缓存启用时尽早并行拉取(一次 header-only 目录遍历 + 每会话一次 stat,
3437
+ // 与 listSessions 内部同量级);不可用/失败回落 null → TTL 兜底。
3438
+ const sessionPersistence = titleCache !== null ? ctx.get('sessionPersistence') : undefined
3439
+ const revisionsPromise = sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function'
3440
+ ? sessionPersistence.listSnapshots().then((snapshots) => {
3441
+ const revisions = new Map()
3442
+ for (const snapshot of Array.isArray(snapshots) ? snapshots : []) {
3443
+ if (typeof snapshot?.header?.id === 'string' && typeof snapshot.revision === 'string') revisions.set(snapshot.header.id, snapshot.revision)
3444
+ }
3445
+ return revisions
3446
+ }).catch(() => null)
3447
+ : Promise.resolve(null)
3448
+ const [records, deletedSnapshot] = await Promise.all([
3449
+ sessionQuery.listSessions(),
3450
+ loadDeletedSessions(dshHome),
3451
+ ])
3452
+ const deleted = deletedSnapshot.items
3453
+ const revisions = await revisionsPromise
3336
3454
  const archivedIds = workspaceRegistry !== undefined && Array.isArray(workspaceRegistry.archivedSessionIds)
3337
3455
  ? [...workspaceRegistry.archivedSessionIds]
3338
3456
  : []
3339
3457
  const archivedSet = new Set(archivedIds)
3340
- const deleted = (await loadDeletedSessions(dshHome)).items
3341
3458
  const deletedSet = new Set(deleted.map((item) => item.id))
3342
3459
  // scope=archived 只保留归档条目(含 live 会话被归档的情况);此时仍需标题。
3343
3460
  const recordsInScope = scope === 'archived'
3344
3461
  ? records.filter((record) => archivedSet.has(record.header.id))
3345
3462
  : records
3346
- // 标题批量折叠:只对 scope 内的 id 取(按请求序);缺席时逐个回落为默认。
3347
- const ids = recordsInScope.map((record) => record.header.id)
3463
+ // 标题折叠(缓存优先):只重读新增/revision 变更/live 超时的条目;fulfilled 才入缓存
3464
+ //(rejected 留空且不缓存,下次自动重试)。缓存条目按插入序 LRU 式淘汰封顶。
3465
+ const staleRecords = []
3466
+ if (titleCache !== null) {
3467
+ for (const record of recordsInScope) {
3468
+ if (deletedSet.has(record.header.id)) continue
3469
+ if (!sessionTitleFresh(titleCache.get(record.header.id), record, revisions, now)) staleRecords.push(record)
3470
+ }
3471
+ } else {
3472
+ for (const record of recordsInScope) {
3473
+ if (!deletedSet.has(record.header.id)) staleRecords.push(record)
3474
+ }
3475
+ }
3348
3476
  const titles = new Map()
3349
- if (typeof sessionQuery.readTitleSnapshots === 'function' && ids.length > 0) {
3350
- const observed = await sessionQuery.readTitleSnapshots(ids)
3477
+ if (typeof sessionQuery.readTitleSnapshots === 'function' && staleRecords.length > 0) {
3478
+ const observed = await sessionQuery.readTitleSnapshots(staleRecords.map((record) => record.header.id))
3351
3479
  for (const entry of observed) {
3352
3480
  if (entry?.sessionId !== undefined && entry?.status === 'fulfilled' && entry.value?.title?.title !== undefined) {
3353
3481
  titles.set(entry.sessionId, entry.value.title.title)
3354
3482
  }
3355
3483
  }
3484
+ if (titleCache !== null) {
3485
+ const recordById = new Map(staleRecords.map((record) => [record.header.id, record]))
3486
+ for (const [id, title] of titles) {
3487
+ const record = recordById.get(id)
3488
+ if (record === undefined) continue
3489
+ titleCache.set(id, { title, revision: revisions !== null ? revisions.get(id) : undefined, at: now, live: record.live === true })
3490
+ }
3491
+ while (titleCache.size > SESSIONS_TITLE_MAX_ENTRIES) {
3492
+ const oldest = titleCache.keys().next().value
3493
+ if (oldest === undefined) break
3494
+ titleCache.delete(oldest)
3495
+ }
3496
+ // 落盘随 RPC 收敛(不留在 Fiber 外):仅本次确实重读过标题才写,原子替换 0600。
3497
+ if (titles.size > 0) {
3498
+ try { await saveSessionTitles(dshHome, titleCache) } catch (_) {}
3499
+ }
3500
+ }
3356
3501
  }
3357
3502
  const items = []
3358
3503
  for (const record of recordsInScope) {
@@ -3360,7 +3505,7 @@ async function listSessionsForManage(ctx, dshHome, scope = 'all') {
3360
3505
  if (deletedSet.has(id)) continue
3361
3506
  items.push({
3362
3507
  id,
3363
- title: titles.get(id) ?? '',
3508
+ title: titles.get(id) ?? (titleCache !== null ? titleCache.get(id)?.title : undefined) ?? '',
3364
3509
  cwd: record.header.cwd ?? null,
3365
3510
  createdAt: record.header.createdAt ?? 0,
3366
3511
  live: record.live === true,
@@ -3397,20 +3542,27 @@ async function resolveSessionBytesForIds(ctx, ids, cache) {
3397
3542
  headerById = null
3398
3543
  }
3399
3544
  }
3400
- for (const id of misses) {
3401
- const header = headerById === null ? undefined : headerById.get(id)
3402
- if (header === undefined) {
3403
- bytes[id] = null
3404
- continue
3405
- }
3406
- const value = await sessionDirectoryBytes(ctx, header)
3407
- cache.set(id, { bytes: value, at: Date.now() })
3408
- if (cache.size > SESSIONS_BYTES_MAX_ENTRIES) {
3409
- const oldest = cache.keys().next().value
3410
- if (oldest !== undefined) cache.delete(oldest)
3545
+ // 未命中目录 stat 并发执行(8 路封顶):首个列表加载的 stat 风暴从串行排队压成小批并行。
3546
+ const SESSIONS_BYTES_STAT_CONCURRENCY = 8
3547
+ let statCursor = 0
3548
+ const statWorkers = Array.from({ length: Math.min(SESSIONS_BYTES_STAT_CONCURRENCY, misses.length) }, async () => {
3549
+ while (statCursor < misses.length) {
3550
+ const id = misses[statCursor++]
3551
+ const header = headerById === null ? undefined : headerById.get(id)
3552
+ if (header === undefined) {
3553
+ bytes[id] = null
3554
+ continue
3555
+ }
3556
+ const value = await sessionDirectoryBytes(ctx, header)
3557
+ cache.set(id, { bytes: value, at: Date.now() })
3558
+ if (cache.size > SESSIONS_BYTES_MAX_ENTRIES) {
3559
+ const oldest = cache.keys().next().value
3560
+ if (oldest !== undefined) cache.delete(oldest)
3561
+ }
3562
+ bytes[id] = value
3411
3563
  }
3412
- bytes[id] = value
3413
- }
3564
+ })
3565
+ await Promise.all(statWorkers)
3414
3566
  return bytes
3415
3567
  }
3416
3568
 
@@ -3489,13 +3641,13 @@ async function viewSessionPage(ctx, id, cursor, cacheRef, limit = SESSIONS_VIEW_
3489
3641
  }
3490
3642
 
3491
3643
  // 内容搜索:逐会话 filterEvents 文本谓词(语义、大小写不敏感、空白灵活),带预算约束。
3492
- async function searchSessionsContent(ctx, dshHome, query, scope = 'all') {
3644
+ async function searchSessionsContent(ctx, dshHome, query, scope = 'all', titleCache = null) {
3493
3645
  const sessionQuery = ctx.get('sessionQuery')
3494
3646
  const result = { available: sessionQuery !== undefined && typeof sessionQuery.filterEvents === 'function', query, scope, hits: [] }
3495
3647
  if (!result.available) return result
3496
3648
  const q = typeof query === 'string' ? query.trim() : ''
3497
3649
  if (q === '') return result
3498
- const listed = await listSessionsForManage(ctx, dshHome)
3650
+ const listed = await listSessionsForManage(ctx, dshHome, 'all', titleCache)
3499
3651
  if (!listed.available) return result
3500
3652
  // scope=all 搜全部会话(含 live,readSession/filterEvents 均支持 live 快照);archived 只搜归档冷会话。
3501
3653
  const targets = scope === 'archived'
@@ -3858,6 +4010,12 @@ function apply(ctx) {
3858
4010
  // 会话体积懒加载缓存(v0.36):宿主进程不重启就一直在——浏览器刷新/面板重开直接命中,
3859
4011
  // 不用重新 readdir+stat(归档冷会话体积不变;live 会话靠 TTL 兜底、删除时主动失效)。
3860
4012
  const sessionBytesCache = new Map()
4013
+ // 会话标题缓存(v1.1.3):revision 键控 + 磁盘持久化,宿主启动即异步预加载,
4014
+ // 首个 sessions-list 前等待填充完成(失败静默回落全量拉取)。
4015
+ const sessionTitleCache = new Map()
4016
+ const sessionTitlesReady = loadSessionTitles(dshHome).then((entries) => {
4017
+ for (const [id, entry] of entries) sessionTitleCache.set(id, entry)
4018
+ }).catch(() => {})
3861
4019
  // 会话详情快照缓存(v0.36):单槽位只留最近打开的会话,翻页零重复 readSession。
3862
4020
  const sessionViewCache = { id: null, snapshot: null, at: 0, live: false }
3863
4021
  let usageIndexPromise = loadUsageIndex(dshHome)
@@ -4214,6 +4372,7 @@ function apply(ctx) {
4214
4372
  ctx.effect(() => () => { backupIntegrity.dispose().catch(() => {}) }, 'dsh-service backup restore plans')
4215
4373
  ctx.effect(() => () => sessionDeletePlans.clear(), 'dsh-service session delete plans')
4216
4374
  ctx.effect(() => () => sessionBytesCache.clear(), 'dsh-service session bytes cache')
4375
+ ctx.effect(() => () => sessionTitleCache.clear(), 'dsh-service session title cache')
4217
4376
  ctx.effect(() => { sessionViewCache.id = null; sessionViewCache.snapshot = null }, 'dsh-service session view cache')
4218
4377
  ctx.effect(() => () => {
4219
4378
  quotaDisposed = true
@@ -4367,6 +4526,19 @@ function apply(ctx) {
4367
4526
  'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
4368
4527
  return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
4369
4528
 
4529
+ } },
4530
+ 'plugin-restart': { feature: 'healthDiagnostics', audit: true, handle: async (payload, rpcEndpoint) => {
4531
+ try {
4532
+ const result = await restartPluginEntry(ctx, payload?.entryId)
4533
+ if (result.ok) return { ok: true, value: {} }
4534
+ // 稳定业务码(unknown-plugin / loader-unavailable / plugin-disabled / not-failed /
4535
+ // restart-failed)走严格 RPC 归一(strictRpcResult),客户端词典映射;restart-failed
4536
+ // 的底层错误原文并入 message(码: 详情 后缀语义)。
4537
+ return rpcFailure(new Error(result.code === 'restart-failed' ? `restart-failed: ${result.error}` : result.code))
4538
+ } catch (error) {
4539
+ return rpcTechnicalFailure(error)
4540
+ }
4541
+
4370
4542
  } },
4371
4543
  'usage': { feature: 'modelUsage', handle: async (payload, rpcEndpoint) => {
4372
4544
  return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
@@ -5071,7 +5243,8 @@ function apply(ctx) {
5071
5243
  'sessions-list': { feature: 'sessionManager', handle: async (payload, rpcEndpoint) => {
5072
5244
  const scope = payload?.scope === 'archived' || payload?.scope === 'deleted' ? payload.scope : 'all'
5073
5245
  try {
5074
- const value = await listSessionsForManage(ctx, dshHome, scope)
5246
+ await sessionTitlesReady
5247
+ const value = await listSessionsForManage(ctx, dshHome, scope, sessionTitleCache)
5075
5248
  // 已删除记录独立下发(供「已删除」筛选):字段只为展示,绝不包含内容。
5076
5249
  return { ok: true, value }
5077
5250
  } catch (error) {
@@ -5115,7 +5288,8 @@ function apply(ctx) {
5115
5288
  const query = typeof payload?.query === 'string' ? payload.query : ''
5116
5289
  const scope = payload?.scope === 'archived' ? 'archived' : 'all'
5117
5290
  try {
5118
- return { ok: true, value: await searchSessionsContent(ctx, dshHome, query, scope) }
5291
+ await sessionTitlesReady
5292
+ return { ok: true, value: await searchSessionsContent(ctx, dshHome, query, scope, sessionTitleCache) }
5119
5293
  } catch (error) {
5120
5294
  return rpcTechnicalFailure(error)
5121
5295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -19,6 +19,8 @@
19
19
  "index.js",
20
20
  "quota-adapters.js",
21
21
  "backup-integrity.js",
22
+ "plugin-health.js",
23
+ "plugin-compat.js",
22
24
  "client.js",
23
25
  "cordis.patch.yml",
24
26
  "README.md",