@gehennawu/dsh-service 1.3.0 → 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/index.js CHANGED
@@ -1745,12 +1745,37 @@ function pruneSessionErrors(session, cutoff) {
1745
1745
  }
1746
1746
  }
1747
1747
 
1748
- function foldUsageEvents(ctx, record, previous, events) {
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) {
1749
1773
  const project = projectForCwd(ctx, record.header.cwd)
1750
- const session = previous || { revision: '', lastSeq: Math.max(0, record.header.seedLength || 0) - 1, project, currentModel: null, hours: {} }
1774
+ const cut = validSessionOffset(inheritedEventCount) ?? 0
1775
+ const session = previous || { revision: '', lastSeq: cut - 1, project, currentModel: null, hours: {} }
1751
1776
  session.project = project
1752
1777
  for (const event of events) {
1753
- if (event.seq < (record.header.seedLength || 0)) continue
1778
+ if (event.seq < cut) continue
1754
1779
  session.lastSeq = Math.max(session.lastSeq, event.seq)
1755
1780
  if (event.type === 'request/header') {
1756
1781
  const provider = event.data?.header?.config?.provider
@@ -1848,9 +1873,10 @@ async function refreshUsageIndex(ctx, dshHome, index) {
1848
1873
  const revision = revisionKey(record.revision)
1849
1874
  const previous = index.sessions[id]
1850
1875
  if (previous?.revision === revision) continue
1851
- const fromSeq = previous === undefined ? Math.max(0, record.header.seedLength || 0) : previous.lastSeq + 1
1876
+ const fromSeq = usageReadStart(previous)
1852
1877
  const read = await persistence.readFrom(record.header.id, fromSeq)
1853
- const next = foldUsageEvents(ctx, record, previous, read.events)
1878
+ const inheritedEventCount = inheritedEventCountFor(record, read)
1879
+ const next = foldUsageEvents(ctx, record, previous, usageReadEvents(read), inheritedEventCount)
1854
1880
  next.revision = revision
1855
1881
  index.sessions[id] = next
1856
1882
  }
@@ -2274,7 +2300,11 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2274
2300
  pluginReport = { available: false, total: 0, issues: [] }
2275
2301
  }
2276
2302
  if (!pluginReport.available) add('plugins', 'info', 'unavailable')
2277
- else checks.push(pluginCheckItem(pluginReport))
2303
+ else {
2304
+ const pluginCheck = pluginCheckItem(pluginReport)
2305
+ if (pluginCheck.status === 'info') pluginCheck.advisory = true
2306
+ checks.push(pluginCheck)
2307
+ }
2278
2308
 
2279
2309
  // v1.3 插件兼容性:对照已核实的 alpha 破坏面清单(client-runtime 供应商移除、SQLite
2280
2310
  // persistence 移除、聊天/统计条 CSS 哈希漂移、data-time-hover-root 删除)扫描启用插件的
@@ -3187,7 +3217,15 @@ function buildSubagentDispatchRecord(agent, parent, dispatch, options = {}) {
3187
3217
 
3188
3218
  /** 父会话事件尾向上扫最近一条带数字 `data.turn` 的事件(≤scanLimit 条);无则 undefined。 */
3189
3219
  function lastSubagentTurn(parent, scanLimit = 50) {
3190
- const events = parent?.session?.events
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
+ }
3191
3229
  if (!Array.isArray(events)) return undefined
3192
3230
  const limit = Number.isFinite(scanLimit) && scanLimit > 0 ? scanLimit : 50
3193
3231
  const from = Math.max(0, events.length - limit)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
package/plugin-compat.js CHANGED
@@ -338,6 +338,9 @@ export async function collectPluginCompat(ctx, options = {}) {
338
338
  const moduleName = entry?.options?.name
339
339
  if (typeof entry?.id !== 'string' || typeof moduleName !== 'string') continue
340
340
  if (entry.disabled === true) continue
341
+ // 无法形成合法包名的条目跳过且不计 scanned:`cordis:include` 这类 Cordis 内建声明名、
342
+ // 相对路径/`file:` 形态不是可扫描的 npm 插件(没有清单可查),不该渲染「未能扫描」提示。
343
+ if (packageNameOf(moduleName) === null) continue
341
344
  scanned += 1
342
345
  let result
343
346
  if (options.noCache === true) {
package/plugin-health.js CHANGED
@@ -27,41 +27,76 @@ export const PLUGIN_FIBER_STATE = Object.freeze({
27
27
  UNLOADING: 5,
28
28
  })
29
29
 
30
- /** 异常相位 → 检查项等级:失败是真故障(error),依赖未就绪/加载中按 warning 提示。 */
30
+ /** 异常相位 → 检查项等级:失败是真故障;启动中按 warning;已释放/未知按 info。 */
31
31
  export function issueLevelOf(phase) {
32
32
  switch (phase) {
33
33
  case 'failed': return 'error'
34
34
  case 'pending':
35
35
  case 'loading':
36
36
  case 'unloading': return 'warning'
37
+ case 'disposed':
38
+ case 'unknown': return 'info'
37
39
  default: return null
38
40
  }
39
41
  }
40
42
 
41
- /** fiber.state 数值 → 相位串;未知/无 fiber 一律按加载中(瞬态/声明未挂载)。 */
43
+ /** fiber.state 数值 → 相位串;无 fiber 是启动瞬态,未知数值明确标为 unknown。 */
42
44
  function phaseOfState(state) {
43
45
  switch (state) {
44
46
  case PLUGIN_FIBER_STATE.PENDING: return 'pending'
45
47
  case PLUGIN_FIBER_STATE.LOADING: return 'loading'
46
48
  case PLUGIN_FIBER_STATE.ACTIVE: return 'active'
47
49
  case PLUGIN_FIBER_STATE.FAILED: return 'failed'
50
+ case PLUGIN_FIBER_STATE.DISPOSED: return 'disposed'
48
51
  case PLUGIN_FIBER_STATE.UNLOADING: return 'unloading'
49
- default: return 'loading'
52
+ default: return 'unknown'
50
53
  }
51
54
  }
52
55
 
56
+ const DEFAULT_STARTUP_GRACE_MS = 5000
57
+ const MAX_PLUGIN_ERROR_LENGTH = 4096
58
+
59
+ function clipPluginError(value) {
60
+ let message
61
+ try {
62
+ message = String(value?.message || value)
63
+ } catch (_) {
64
+ message = 'unknown plugin failure'
65
+ }
66
+ message = message
67
+ .replace(/(Bearer\s+)[^\s]+/gi, '$1<redacted>')
68
+ .replace(/([?&](?:api[_-]?key|token|access[_-]?token|secret|password)=)[^&\s]+/gi, '$1<redacted>')
69
+ .replace(/((?:api[_-]?key|access[_-]?token|secret|password)\s*[:=]\s*)[^,;\s]+/gi, '$1<redacted>')
70
+ .replace(/(?:[A-Za-z]:[\\/]|\/home\/|\/workspace\/)[^\s"'`]+/g, '<redacted-path>')
71
+ return message.length > MAX_PLUGIN_ERROR_LENGTH ? `${message.slice(0, MAX_PLUGIN_ERROR_LENGTH - 1)}…` : message
72
+ }
73
+
74
+ function startupAgeMs(entry, now, options) {
75
+ if (typeof options.ageOf === 'function') {
76
+ const age = Number(options.ageOf(entry))
77
+ return Number.isFinite(age) && age >= 0 ? age : Infinity
78
+ }
79
+ const startedAt = Number(entry?.fiber?.startedAt ?? entry?.startedAt)
80
+ return Number.isFinite(startedAt) && startedAt >= 0 ? Math.max(0, now - startedAt) : 0
81
+ }
82
+
53
83
  /**
54
84
  * 收集插件健康体检结果。
55
85
  *
56
86
  * @param ctx 宿主插件上下文(供 `ctx.get('loader')`)
87
+ * @param options 可选测试/运行时参数:`now`、`startupGraceMs`、`ageOf(entry)`
57
88
  * @returns {available: boolean, total: number, issues: PluginIssue[]}——loader 缺席时
58
- * available=false(调用方降级为 info 检查项,不含任何插件数据)。
89
+ * available=false(调用方降级为 info 检查项,不含任何插件数据)。启动中的 pending/loading 默认有 5 秒宽限。
59
90
  */
60
- export async function collectPluginHealth(ctx) {
91
+ export async function collectPluginHealth(ctx, options = {}) {
61
92
  const loader = ctx.get('loader')
62
93
  if (loader === undefined) return { available: false, total: 0, issues: [] }
63
94
  const issues = []
64
95
  let total = 0
96
+ const now = Number.isFinite(options.now) ? options.now : Date.now()
97
+ const startupGraceMs = Number.isFinite(options.startupGraceMs) && options.startupGraceMs >= 0
98
+ ? options.startupGraceMs
99
+ : DEFAULT_STARTUP_GRACE_MS
65
100
  for (const entry of loader.entries()) {
66
101
  if (entry?.options?.group === true) continue
67
102
  const moduleName = entry?.options?.name
@@ -72,8 +107,9 @@ export async function collectPluginHealth(ctx) {
72
107
  const fiber = entry.fiber
73
108
  const phase = fiber === undefined ? 'loading' : phaseOfState(fiber.state)
74
109
  if (phase === 'active') continue
110
+ if ((phase === 'pending' || phase === 'loading') && startupGraceMs > 0 && startupAgeMs(entry, now, options) < startupGraceMs) continue
75
111
  const error = phase === 'failed' && fiber?._error != null
76
- ? String(fiber._error?.message || fiber._error)
112
+ ? clipPluginError(fiber._error)
77
113
  : null
78
114
  const missingDeps = phase === 'pending'
79
115
  ? Object.keys(fiber.inject || {}).filter((key) => !(fiber.store !== undefined && fiber.store[key]))
@@ -90,17 +126,18 @@ export async function collectPluginHealth(ctx) {
90
126
  }
91
127
 
92
128
  /**
93
- * 聚合为一个诊断检查项(追加在 checks 尾部)。detail 三段 `total:failed:pending`:
94
- * 客户端用它渲染「共 N 个插件,全部正常」或异常摘要;停用插件完全不进统计
95
- * (failed 是 error,其余异常相位归 pending 桶按 warning 处理)。
129
+ * 聚合为一个诊断检查项(追加在 checks 尾部)。detail 四段 `total:failed:pending:informational`:
130
+ * 客户端用它渲染「共 N 个插件,全部正常」或异常摘要;停用插件完全不进统计。
131
+ * 旧客户端仍可读取前三段;failed 是 error,启动/卸载相位按 warning,disposed/unknown 单独作为 info。
96
132
  */
97
133
  export function pluginCheckItem(report) {
98
134
  const failed = report.issues.filter((issue) => issue.phase === 'failed').length
99
- const pending = report.issues.filter((issue) => issue.phase !== 'failed').length
135
+ const pending = report.issues.filter((issue) => issue.phase === 'pending' || issue.phase === 'loading' || issue.phase === 'unloading').length
136
+ const informational = report.issues.filter((issue) => issue.phase === 'disposed' || issue.phase === 'unknown').length
100
137
  return {
101
138
  id: 'plugins',
102
- status: failed > 0 ? 'error' : pending > 0 ? 'warning' : 'ok',
103
- detail: `${report.total}:${failed}:${pending}`,
139
+ status: failed > 0 ? 'error' : pending > 0 ? 'warning' : informational > 0 ? 'info' : 'ok',
140
+ detail: `${report.total}:${failed}:${pending}:${informational}`,
104
141
  }
105
142
  }
106
143