@gehennawu/dsh-service 1.1.2 → 1.4.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.en.md +6 -4
- package/README.md +6 -4
- package/client.js +1 -1
- package/index.js +244 -32
- package/package.json +3 -1
- package/plugin-compat.js +377 -0
- package/plugin-health.js +175 -0
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',
|
|
@@ -1733,12 +1745,37 @@ function pruneSessionErrors(session, cutoff) {
|
|
|
1733
1745
|
}
|
|
1734
1746
|
}
|
|
1735
1747
|
|
|
1736
|
-
function
|
|
1748
|
+
function validSessionOffset(value) {
|
|
1749
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0) ? value : undefined
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// alpha.4 moves the fork cut out of SessionHeader and into body-bearing reads;
|
|
1753
|
+
// older DSH releases expose it as header/meta.seedLength. Keep this normalization
|
|
1754
|
+
// at the persistence seam so the usage fold never has to know which runtime spoke.
|
|
1755
|
+
function inheritedEventCountFor(record, read) {
|
|
1756
|
+
const fromRead = validSessionOffset(read?.inheritedEventCount)
|
|
1757
|
+
if (fromRead !== undefined) return fromRead
|
|
1758
|
+
const fromMeta = validSessionOffset(read?.meta?.seedLength)
|
|
1759
|
+
if (fromMeta !== undefined) return fromMeta
|
|
1760
|
+
const fromHeader = validSessionOffset(record?.header?.seedLength)
|
|
1761
|
+
return fromHeader ?? 0
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function usageReadStart(previous) {
|
|
1765
|
+
return previous === undefined ? 0 : Math.max(0, previous.lastSeq + 1)
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function usageReadEvents(read) {
|
|
1769
|
+
return Array.isArray(read?.events) ? read.events : []
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function foldUsageEvents(ctx, record, previous, events, inheritedEventCount = 0) {
|
|
1737
1773
|
const project = projectForCwd(ctx, record.header.cwd)
|
|
1738
|
-
const
|
|
1774
|
+
const cut = validSessionOffset(inheritedEventCount) ?? 0
|
|
1775
|
+
const session = previous || { revision: '', lastSeq: cut - 1, project, currentModel: null, hours: {} }
|
|
1739
1776
|
session.project = project
|
|
1740
1777
|
for (const event of events) {
|
|
1741
|
-
if (event.seq <
|
|
1778
|
+
if (event.seq < cut) continue
|
|
1742
1779
|
session.lastSeq = Math.max(session.lastSeq, event.seq)
|
|
1743
1780
|
if (event.type === 'request/header') {
|
|
1744
1781
|
const provider = event.data?.header?.config?.provider
|
|
@@ -1836,9 +1873,10 @@ async function refreshUsageIndex(ctx, dshHome, index) {
|
|
|
1836
1873
|
const revision = revisionKey(record.revision)
|
|
1837
1874
|
const previous = index.sessions[id]
|
|
1838
1875
|
if (previous?.revision === revision) continue
|
|
1839
|
-
const fromSeq = previous
|
|
1876
|
+
const fromSeq = usageReadStart(previous)
|
|
1840
1877
|
const read = await persistence.readFrom(record.header.id, fromSeq)
|
|
1841
|
-
const
|
|
1878
|
+
const inheritedEventCount = inheritedEventCountFor(record, read)
|
|
1879
|
+
const next = foldUsageEvents(ctx, record, previous, usageReadEvents(read), inheritedEventCount)
|
|
1842
1880
|
next.revision = revision
|
|
1843
1881
|
index.sessions[id] = next
|
|
1844
1882
|
}
|
|
@@ -2253,11 +2291,46 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
|
|
|
2253
2291
|
const nodeMajor = parseInt(process.versions.node, 10)
|
|
2254
2292
|
add('node-version', Number.isFinite(nodeMajor) && nodeMajor >= requiredNodeMajor ? 'ok' : 'warning', `${process.version}:${requiredNodeMajor}`)
|
|
2255
2293
|
|
|
2294
|
+
// v1.3 插件健康检查:只检查异常——loader 缺席降级为 info 检查项、不带异常行数组;
|
|
2295
|
+
// 停用条目(无论内置/自定义)一律不算异常,正常插件不下发(官方设置页已有完整清单与开关)。
|
|
2296
|
+
let pluginReport
|
|
2297
|
+
try {
|
|
2298
|
+
pluginReport = await collectPluginHealth(ctx)
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
pluginReport = { available: false, total: 0, issues: [] }
|
|
2301
|
+
}
|
|
2302
|
+
if (!pluginReport.available) add('plugins', 'info', 'unavailable')
|
|
2303
|
+
else {
|
|
2304
|
+
const pluginCheck = pluginCheckItem(pluginReport)
|
|
2305
|
+
if (pluginCheck.status === 'info') pluginCheck.advisory = true
|
|
2306
|
+
checks.push(pluginCheck)
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// v1.3 插件兼容性:对照已核实的 alpha 破坏面清单(client-runtime 供应商移除、SQLite
|
|
2310
|
+
// persistence 移除、聊天/统计条 CSS 哈希漂移、data-time-hover-root 删除)扫描启用插件的
|
|
2311
|
+
// 清单与入口代码;loader 缺席降级为 info 检查项、不带扫描结果。
|
|
2312
|
+
let compatReport
|
|
2313
|
+
try {
|
|
2314
|
+
compatReport = await collectPluginCompat(ctx)
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
compatReport = { available: false, scanned: 0, issues: [], declaredOnly: [], unknown: [] }
|
|
2317
|
+
}
|
|
2318
|
+
if (!compatReport.available) add('plugin-compat', 'info', 'unavailable')
|
|
2319
|
+
else checks.push(pluginCompatCheckItem(compatReport))
|
|
2320
|
+
|
|
2256
2321
|
let status = 'ok'
|
|
2257
2322
|
// advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
|
|
2258
2323
|
if (checks.some((check) => check.status === 'error')) status = 'error'
|
|
2259
2324
|
else if (checks.some((check) => check.status === 'warning' && check.advisory !== true)) status = 'warning'
|
|
2260
|
-
return {
|
|
2325
|
+
return {
|
|
2326
|
+
status,
|
|
2327
|
+
checkedAt: Date.now(),
|
|
2328
|
+
checks,
|
|
2329
|
+
...(pluginReport.available ? { pluginIssues: pluginReport.issues } : {}),
|
|
2330
|
+
...(compatReport.available
|
|
2331
|
+
? { pluginCompat: { scanned: compatReport.scanned, issues: compatReport.issues, declaredOnly: compatReport.declaredOnly, unknown: compatReport.unknown } }
|
|
2332
|
+
: {}),
|
|
2333
|
+
}
|
|
2261
2334
|
}
|
|
2262
2335
|
|
|
2263
2336
|
async function collectHealth(ctx) {
|
|
@@ -3144,7 +3217,15 @@ function buildSubagentDispatchRecord(agent, parent, dispatch, options = {}) {
|
|
|
3144
3217
|
|
|
3145
3218
|
/** 父会话事件尾向上扫最近一条带数字 `data.turn` 的事件(≤scanLimit 条);无则 undefined。 */
|
|
3146
3219
|
function lastSubagentTurn(parent, scanLimit = 50) {
|
|
3147
|
-
|
|
3220
|
+
let events
|
|
3221
|
+
try {
|
|
3222
|
+
if (typeof parent?.session?.snapshotEvents === 'function') events = parent.session.snapshotEvents()
|
|
3223
|
+
} catch (_) {
|
|
3224
|
+
events = undefined
|
|
3225
|
+
}
|
|
3226
|
+
if (!Array.isArray(events)) {
|
|
3227
|
+
try { events = parent?.session?.events } catch (_) { events = undefined }
|
|
3228
|
+
}
|
|
3148
3229
|
if (!Array.isArray(events)) return undefined
|
|
3149
3230
|
const limit = Number.isFinite(scanLimit) && scanLimit > 0 ? scanLimit : 50
|
|
3150
3231
|
const from = Math.max(0, events.length - limit)
|
|
@@ -3256,6 +3337,46 @@ async function saveDeletedSessions(dshHome, data) {
|
|
|
3256
3337
|
}
|
|
3257
3338
|
}
|
|
3258
3339
|
|
|
3340
|
+
// 标题缓存磁盘持久化:{version, items: {id: {title, revision?, live}}}——只存标题与指纹,
|
|
3341
|
+
// 不含任何会话内容;损坏/版本不符整体丢弃(回退全量拉取,宁慢勿错)。at 不落盘:
|
|
3342
|
+
// 加载后按 0 处理,revision 对不上或缺失时自然判定过期重读。
|
|
3343
|
+
function createEmptySessionTitles() {
|
|
3344
|
+
return { version: SESSIONS_TITLE_VERSION, items: {} }
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
async function loadSessionTitles(dshHome) {
|
|
3348
|
+
try {
|
|
3349
|
+
const parsed = JSON.parse(await readFile(join(dshHome, SESSIONS_TITLE_FILE), 'utf8'))
|
|
3350
|
+
if (parsed?.version !== SESSIONS_TITLE_VERSION || typeof parsed.items !== 'object' || parsed.items === null) return new Map()
|
|
3351
|
+
const entries = new Map()
|
|
3352
|
+
for (const [id, entry] of Object.entries(parsed.items)) {
|
|
3353
|
+
if (typeof id !== 'string' || id === '') continue
|
|
3354
|
+
if (typeof entry?.title !== 'string' || typeof entry.revision !== 'string') continue
|
|
3355
|
+
entries.set(id, { title: entry.title, revision: entry.revision, live: entry.live === true, at: 0 })
|
|
3356
|
+
}
|
|
3357
|
+
return entries
|
|
3358
|
+
} catch (_) {
|
|
3359
|
+
return new Map()
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
async function saveSessionTitles(dshHome, cache) {
|
|
3364
|
+
const items = {}
|
|
3365
|
+
for (const [id, entry] of cache) {
|
|
3366
|
+
if (typeof entry?.title !== 'string') continue
|
|
3367
|
+
items[id] = { title: entry.title, ...(typeof entry.revision === 'string' ? { revision: entry.revision } : {}), live: entry.live === true }
|
|
3368
|
+
}
|
|
3369
|
+
await mkdir(dshHome, { recursive: true })
|
|
3370
|
+
const target = join(dshHome, SESSIONS_TITLE_FILE)
|
|
3371
|
+
const temporary = `${target}.${randomUUID()}.tmp`
|
|
3372
|
+
try {
|
|
3373
|
+
await writeFile(temporary, JSON.stringify({ version: SESSIONS_TITLE_VERSION, items }), { mode: 0o600 })
|
|
3374
|
+
await rename(temporary, target)
|
|
3375
|
+
} finally {
|
|
3376
|
+
await rm(temporary, { force: true })
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3259
3380
|
/** 单个会话的事件文本(与官方 extractSessionEventText 语义一致,不依赖官方内部包)。 */
|
|
3260
3381
|
function sessionEventText(event) {
|
|
3261
3382
|
if (typeof event !== 'object' || event === null) return ''
|
|
@@ -3320,9 +3441,26 @@ async function sessionDirectoryBytes(ctx, header) {
|
|
|
3320
3441
|
}
|
|
3321
3442
|
}
|
|
3322
3443
|
|
|
3444
|
+
// 标题缓存条目是否仍新鲜:有 revision 源(sessionPersistence.listSnapshots 可用)时按指纹
|
|
3445
|
+
// 精确判定——文件没变标题就不变,冷会话永久命中;快照清单里缺席的 id(纯内存 live 会话)
|
|
3446
|
+
// 及 revision 源不可用时按 TTL 兜底。宁重读不展示旧标题。
|
|
3447
|
+
function sessionTitleFresh(entry, record, revisions, now) {
|
|
3448
|
+
if (entry === undefined) return false
|
|
3449
|
+
if (revisions !== null) {
|
|
3450
|
+
const revision = revisions.get(record.header.id)
|
|
3451
|
+
if (revision !== undefined) return entry.revision === revision
|
|
3452
|
+
return now - entry.at < SESSIONS_TITLE_LIVE_TTL_MS
|
|
3453
|
+
}
|
|
3454
|
+
const ttl = record.live === true ? SESSIONS_TITLE_LIVE_TTL_MS : SESSIONS_TITLE_COLD_TTL_MS
|
|
3455
|
+
return now - entry.at < ttl
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3323
3458
|
// 会话管理列表:live + 冷会话合并,标注归档/已删除,补标题。列表不携带体积——
|
|
3324
3459
|
// 体积走 sessions-bytes 懒加载(v0.36:全量下发意味着打开/切换就要逐个 readdir+stat)。
|
|
3325
|
-
|
|
3460
|
+
// v1.1.3:readTitleSnapshots 对每个冷会话全量解析整份日志(官方零缓存),改为 revision
|
|
3461
|
+
// 键控缓存——列表加载只对新增/变更条目重读,重复加载/面板重开零整库扫描;listing 与
|
|
3462
|
+
// 已删除清单并行获取。
|
|
3463
|
+
async function listSessionsForManage(ctx, dshHome, scope = 'all', titleCache = null) {
|
|
3326
3464
|
const sessionQuery = ctx.get('sessionQuery')
|
|
3327
3465
|
const workspaceRegistry = ctx.get('workspaceRegistry')
|
|
3328
3466
|
const available = sessionQuery !== undefined && typeof sessionQuery.listSessions === 'function'
|
|
@@ -3332,27 +3470,72 @@ async function listSessionsForManage(ctx, dshHome, scope = 'all') {
|
|
|
3332
3470
|
if (scope === 'deleted') {
|
|
3333
3471
|
return { available: true, items: [], archivedIds: [], deleted: (await loadDeletedSessions(dshHome)).items }
|
|
3334
3472
|
}
|
|
3335
|
-
const
|
|
3473
|
+
const now = Date.now()
|
|
3474
|
+
// revision 源:缓存启用时尽早并行拉取(一次 header-only 目录遍历 + 每会话一次 stat,
|
|
3475
|
+
// 与 listSessions 内部同量级);不可用/失败回落 null → TTL 兜底。
|
|
3476
|
+
const sessionPersistence = titleCache !== null ? ctx.get('sessionPersistence') : undefined
|
|
3477
|
+
const revisionsPromise = sessionPersistence !== undefined && typeof sessionPersistence.listSnapshots === 'function'
|
|
3478
|
+
? sessionPersistence.listSnapshots().then((snapshots) => {
|
|
3479
|
+
const revisions = new Map()
|
|
3480
|
+
for (const snapshot of Array.isArray(snapshots) ? snapshots : []) {
|
|
3481
|
+
if (typeof snapshot?.header?.id === 'string' && typeof snapshot.revision === 'string') revisions.set(snapshot.header.id, snapshot.revision)
|
|
3482
|
+
}
|
|
3483
|
+
return revisions
|
|
3484
|
+
}).catch(() => null)
|
|
3485
|
+
: Promise.resolve(null)
|
|
3486
|
+
const [records, deletedSnapshot] = await Promise.all([
|
|
3487
|
+
sessionQuery.listSessions(),
|
|
3488
|
+
loadDeletedSessions(dshHome),
|
|
3489
|
+
])
|
|
3490
|
+
const deleted = deletedSnapshot.items
|
|
3491
|
+
const revisions = await revisionsPromise
|
|
3336
3492
|
const archivedIds = workspaceRegistry !== undefined && Array.isArray(workspaceRegistry.archivedSessionIds)
|
|
3337
3493
|
? [...workspaceRegistry.archivedSessionIds]
|
|
3338
3494
|
: []
|
|
3339
3495
|
const archivedSet = new Set(archivedIds)
|
|
3340
|
-
const deleted = (await loadDeletedSessions(dshHome)).items
|
|
3341
3496
|
const deletedSet = new Set(deleted.map((item) => item.id))
|
|
3342
3497
|
// scope=archived 只保留归档条目(含 live 会话被归档的情况);此时仍需标题。
|
|
3343
3498
|
const recordsInScope = scope === 'archived'
|
|
3344
3499
|
? records.filter((record) => archivedSet.has(record.header.id))
|
|
3345
3500
|
: records
|
|
3346
|
-
//
|
|
3347
|
-
|
|
3501
|
+
// 标题折叠(缓存优先):只重读新增/revision 变更/live 超时的条目;fulfilled 才入缓存
|
|
3502
|
+
//(rejected 留空且不缓存,下次自动重试)。缓存条目按插入序 LRU 式淘汰封顶。
|
|
3503
|
+
const staleRecords = []
|
|
3504
|
+
if (titleCache !== null) {
|
|
3505
|
+
for (const record of recordsInScope) {
|
|
3506
|
+
if (deletedSet.has(record.header.id)) continue
|
|
3507
|
+
if (!sessionTitleFresh(titleCache.get(record.header.id), record, revisions, now)) staleRecords.push(record)
|
|
3508
|
+
}
|
|
3509
|
+
} else {
|
|
3510
|
+
for (const record of recordsInScope) {
|
|
3511
|
+
if (!deletedSet.has(record.header.id)) staleRecords.push(record)
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3348
3514
|
const titles = new Map()
|
|
3349
|
-
if (typeof sessionQuery.readTitleSnapshots === 'function' &&
|
|
3350
|
-
const observed = await sessionQuery.readTitleSnapshots(
|
|
3515
|
+
if (typeof sessionQuery.readTitleSnapshots === 'function' && staleRecords.length > 0) {
|
|
3516
|
+
const observed = await sessionQuery.readTitleSnapshots(staleRecords.map((record) => record.header.id))
|
|
3351
3517
|
for (const entry of observed) {
|
|
3352
3518
|
if (entry?.sessionId !== undefined && entry?.status === 'fulfilled' && entry.value?.title?.title !== undefined) {
|
|
3353
3519
|
titles.set(entry.sessionId, entry.value.title.title)
|
|
3354
3520
|
}
|
|
3355
3521
|
}
|
|
3522
|
+
if (titleCache !== null) {
|
|
3523
|
+
const recordById = new Map(staleRecords.map((record) => [record.header.id, record]))
|
|
3524
|
+
for (const [id, title] of titles) {
|
|
3525
|
+
const record = recordById.get(id)
|
|
3526
|
+
if (record === undefined) continue
|
|
3527
|
+
titleCache.set(id, { title, revision: revisions !== null ? revisions.get(id) : undefined, at: now, live: record.live === true })
|
|
3528
|
+
}
|
|
3529
|
+
while (titleCache.size > SESSIONS_TITLE_MAX_ENTRIES) {
|
|
3530
|
+
const oldest = titleCache.keys().next().value
|
|
3531
|
+
if (oldest === undefined) break
|
|
3532
|
+
titleCache.delete(oldest)
|
|
3533
|
+
}
|
|
3534
|
+
// 落盘随 RPC 收敛(不留在 Fiber 外):仅本次确实重读过标题才写,原子替换 0600。
|
|
3535
|
+
if (titles.size > 0) {
|
|
3536
|
+
try { await saveSessionTitles(dshHome, titleCache) } catch (_) {}
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3356
3539
|
}
|
|
3357
3540
|
const items = []
|
|
3358
3541
|
for (const record of recordsInScope) {
|
|
@@ -3360,7 +3543,7 @@ async function listSessionsForManage(ctx, dshHome, scope = 'all') {
|
|
|
3360
3543
|
if (deletedSet.has(id)) continue
|
|
3361
3544
|
items.push({
|
|
3362
3545
|
id,
|
|
3363
|
-
title: titles.get(id) ?? '',
|
|
3546
|
+
title: titles.get(id) ?? (titleCache !== null ? titleCache.get(id)?.title : undefined) ?? '',
|
|
3364
3547
|
cwd: record.header.cwd ?? null,
|
|
3365
3548
|
createdAt: record.header.createdAt ?? 0,
|
|
3366
3549
|
live: record.live === true,
|
|
@@ -3397,20 +3580,27 @@ async function resolveSessionBytesForIds(ctx, ids, cache) {
|
|
|
3397
3580
|
headerById = null
|
|
3398
3581
|
}
|
|
3399
3582
|
}
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3583
|
+
// 未命中目录 stat 并发执行(8 路封顶):首个列表加载的 stat 风暴从串行排队压成小批并行。
|
|
3584
|
+
const SESSIONS_BYTES_STAT_CONCURRENCY = 8
|
|
3585
|
+
let statCursor = 0
|
|
3586
|
+
const statWorkers = Array.from({ length: Math.min(SESSIONS_BYTES_STAT_CONCURRENCY, misses.length) }, async () => {
|
|
3587
|
+
while (statCursor < misses.length) {
|
|
3588
|
+
const id = misses[statCursor++]
|
|
3589
|
+
const header = headerById === null ? undefined : headerById.get(id)
|
|
3590
|
+
if (header === undefined) {
|
|
3591
|
+
bytes[id] = null
|
|
3592
|
+
continue
|
|
3593
|
+
}
|
|
3594
|
+
const value = await sessionDirectoryBytes(ctx, header)
|
|
3595
|
+
cache.set(id, { bytes: value, at: Date.now() })
|
|
3596
|
+
if (cache.size > SESSIONS_BYTES_MAX_ENTRIES) {
|
|
3597
|
+
const oldest = cache.keys().next().value
|
|
3598
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
3599
|
+
}
|
|
3600
|
+
bytes[id] = value
|
|
3411
3601
|
}
|
|
3412
|
-
|
|
3413
|
-
|
|
3602
|
+
})
|
|
3603
|
+
await Promise.all(statWorkers)
|
|
3414
3604
|
return bytes
|
|
3415
3605
|
}
|
|
3416
3606
|
|
|
@@ -3489,13 +3679,13 @@ async function viewSessionPage(ctx, id, cursor, cacheRef, limit = SESSIONS_VIEW_
|
|
|
3489
3679
|
}
|
|
3490
3680
|
|
|
3491
3681
|
// 内容搜索:逐会话 filterEvents 文本谓词(语义、大小写不敏感、空白灵活),带预算约束。
|
|
3492
|
-
async function searchSessionsContent(ctx, dshHome, query, scope = 'all') {
|
|
3682
|
+
async function searchSessionsContent(ctx, dshHome, query, scope = 'all', titleCache = null) {
|
|
3493
3683
|
const sessionQuery = ctx.get('sessionQuery')
|
|
3494
3684
|
const result = { available: sessionQuery !== undefined && typeof sessionQuery.filterEvents === 'function', query, scope, hits: [] }
|
|
3495
3685
|
if (!result.available) return result
|
|
3496
3686
|
const q = typeof query === 'string' ? query.trim() : ''
|
|
3497
3687
|
if (q === '') return result
|
|
3498
|
-
const listed = await listSessionsForManage(ctx, dshHome)
|
|
3688
|
+
const listed = await listSessionsForManage(ctx, dshHome, 'all', titleCache)
|
|
3499
3689
|
if (!listed.available) return result
|
|
3500
3690
|
// scope=all 搜全部会话(含 live,readSession/filterEvents 均支持 live 快照);archived 只搜归档冷会话。
|
|
3501
3691
|
const targets = scope === 'archived'
|
|
@@ -3858,6 +4048,12 @@ function apply(ctx) {
|
|
|
3858
4048
|
// 会话体积懒加载缓存(v0.36):宿主进程不重启就一直在——浏览器刷新/面板重开直接命中,
|
|
3859
4049
|
// 不用重新 readdir+stat(归档冷会话体积不变;live 会话靠 TTL 兜底、删除时主动失效)。
|
|
3860
4050
|
const sessionBytesCache = new Map()
|
|
4051
|
+
// 会话标题缓存(v1.1.3):revision 键控 + 磁盘持久化,宿主启动即异步预加载,
|
|
4052
|
+
// 首个 sessions-list 前等待填充完成(失败静默回落全量拉取)。
|
|
4053
|
+
const sessionTitleCache = new Map()
|
|
4054
|
+
const sessionTitlesReady = loadSessionTitles(dshHome).then((entries) => {
|
|
4055
|
+
for (const [id, entry] of entries) sessionTitleCache.set(id, entry)
|
|
4056
|
+
}).catch(() => {})
|
|
3861
4057
|
// 会话详情快照缓存(v0.36):单槽位只留最近打开的会话,翻页零重复 readSession。
|
|
3862
4058
|
const sessionViewCache = { id: null, snapshot: null, at: 0, live: false }
|
|
3863
4059
|
let usageIndexPromise = loadUsageIndex(dshHome)
|
|
@@ -4214,6 +4410,7 @@ function apply(ctx) {
|
|
|
4214
4410
|
ctx.effect(() => () => { backupIntegrity.dispose().catch(() => {}) }, 'dsh-service backup restore plans')
|
|
4215
4411
|
ctx.effect(() => () => sessionDeletePlans.clear(), 'dsh-service session delete plans')
|
|
4216
4412
|
ctx.effect(() => () => sessionBytesCache.clear(), 'dsh-service session bytes cache')
|
|
4413
|
+
ctx.effect(() => () => sessionTitleCache.clear(), 'dsh-service session title cache')
|
|
4217
4414
|
ctx.effect(() => { sessionViewCache.id = null; sessionViewCache.snapshot = null }, 'dsh-service session view cache')
|
|
4218
4415
|
ctx.effect(() => () => {
|
|
4219
4416
|
quotaDisposed = true
|
|
@@ -4367,6 +4564,19 @@ function apply(ctx) {
|
|
|
4367
4564
|
'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
|
|
4368
4565
|
return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
|
|
4369
4566
|
|
|
4567
|
+
} },
|
|
4568
|
+
'plugin-restart': { feature: 'healthDiagnostics', audit: true, handle: async (payload, rpcEndpoint) => {
|
|
4569
|
+
try {
|
|
4570
|
+
const result = await restartPluginEntry(ctx, payload?.entryId)
|
|
4571
|
+
if (result.ok) return { ok: true, value: {} }
|
|
4572
|
+
// 稳定业务码(unknown-plugin / loader-unavailable / plugin-disabled / not-failed /
|
|
4573
|
+
// restart-failed)走严格 RPC 归一(strictRpcResult),客户端词典映射;restart-failed
|
|
4574
|
+
// 的底层错误原文并入 message(码: 详情 后缀语义)。
|
|
4575
|
+
return rpcFailure(new Error(result.code === 'restart-failed' ? `restart-failed: ${result.error}` : result.code))
|
|
4576
|
+
} catch (error) {
|
|
4577
|
+
return rpcTechnicalFailure(error)
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4370
4580
|
} },
|
|
4371
4581
|
'usage': { feature: 'modelUsage', handle: async (payload, rpcEndpoint) => {
|
|
4372
4582
|
return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
|
|
@@ -5071,7 +5281,8 @@ function apply(ctx) {
|
|
|
5071
5281
|
'sessions-list': { feature: 'sessionManager', handle: async (payload, rpcEndpoint) => {
|
|
5072
5282
|
const scope = payload?.scope === 'archived' || payload?.scope === 'deleted' ? payload.scope : 'all'
|
|
5073
5283
|
try {
|
|
5074
|
-
|
|
5284
|
+
await sessionTitlesReady
|
|
5285
|
+
const value = await listSessionsForManage(ctx, dshHome, scope, sessionTitleCache)
|
|
5075
5286
|
// 已删除记录独立下发(供「已删除」筛选):字段只为展示,绝不包含内容。
|
|
5076
5287
|
return { ok: true, value }
|
|
5077
5288
|
} catch (error) {
|
|
@@ -5115,7 +5326,8 @@ function apply(ctx) {
|
|
|
5115
5326
|
const query = typeof payload?.query === 'string' ? payload.query : ''
|
|
5116
5327
|
const scope = payload?.scope === 'archived' ? 'archived' : 'all'
|
|
5117
5328
|
try {
|
|
5118
|
-
|
|
5329
|
+
await sessionTitlesReady
|
|
5330
|
+
return { ok: true, value: await searchSessionsContent(ctx, dshHome, query, scope, sessionTitleCache) }
|
|
5119
5331
|
} catch (error) {
|
|
5120
5332
|
return rpcTechnicalFailure(error)
|
|
5121
5333
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gehennawu/dsh-service",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|