dsh-my-observability 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  本文件记录 dsh-my-observability 的所有版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.1.6] - 2026-09-04
6
+
7
+ ### 变更
8
+
9
+ - fix(observability): 轨迹回放会话下拉可读标题(首条用户消息)——此前只展示 UUID 清单
10
+
11
+ ## [0.1.5] - 2026-09-04
12
+
13
+ ### 变更
14
+
15
+ - feat(observability): #127 资源看门狗自动降级——写入速率/文件大小连续 ≥3 次采样超限自动暂停落盘(事件仍入内存,FIFO 有界不丢)+ 日志告警;连续 ≥3 次正常自动恢复(全量快照补齐降级窗口);API 暴露 degraded 标记(#136)
16
+ - 发版前功能级验证清单(issue #67):verification/dsh-my-observability-0.1.5.md
17
+
5
18
  ## [0.1.4] - 2026-09-03
6
19
 
7
20
  ### 变更
package/README.md CHANGED
@@ -26,7 +26,7 @@ Server 端只读观察 DSH 生命周期事件并记录审计日志:
26
26
  - **重启恢复**:持久化到 `$DSH_HOME/observability/audit.jsonl`(**增量追加** + 防抖批量 flush + 周期 compact 原子快照),重启后完整恢复;升级前旧格式 `audit.json` 自动迁移;
27
27
  - **防膨胀**:每会话最多 2000 条(FIFO 淘汰)、全局 20000 条(轮转淘汰);
28
28
  - **零写放大**:落盘只写新增事件(≈事件本体字节),不会因事件流持续而反复全量重写审计文件;
29
- - **资源监控**:面板「资源」区块展示本进程 CPU/内存 + 审计文件大小与写入速率(15s 采样),写放大/超限自动告警(阈值见 `resource-budget-review`);
29
+ - **资源监控 + 自动降级(资源看门狗)**:面板「资源」区块展示本进程 CPU/内存 + 审计文件大小与写入速率(15s 采样),写放大/超限自动告警(告警列表展示在「资源」区块,阈值见 `resource-budget-review`);写入速率/文件大小连续超限自动**暂停落盘**(事件仍入内存,有界不丢),回落自动恢复(全量快照补齐降级窗口),全程日志告警 + API `degraded` 标记(issue #127)。
30
30
  - **只读观察**:waterfall 事件一律透传 `next()`,绝不改变工具/模型流程。
31
31
 
32
32
  ### 2. 轨迹回放面板(时间轴)
Binary file
package/lib/audit.js CHANGED
@@ -17,6 +17,7 @@ import { MAX_ARG_KEYS, MAX_TEXT_LEN } from './constants.js'
17
17
  /** 注册全部审计监听;返回 disposer 数组(全部经 ctx.on 注册)。 */
18
18
  export function attachAuditListeners(ctx, record) {
19
19
  return [
20
+ ctx.on('session/event', (session, event) => handleSessionEvent(session, event, record)),
20
21
  ctx.on('agent/status', (payload) => handleStatus(payload, record)),
21
22
  ctx.on('llm/stream', (options, next) => handleStream(options, next, record)),
22
23
  ctx.on('tools/pre-execute', (exec, next) => handlePreExecute(exec, next, record)),
@@ -24,6 +25,43 @@ export function attachAuditListeners(ctx, record) {
24
25
  ]
25
26
  }
26
27
 
28
+ /**
29
+ * session/event → user_message 事件(会话标题来源)。
30
+ * 轨迹回放面板需要"对话可读标题"而非 UUID:从每个会话真实用户的首条
31
+ * 消息截断生成(跳过插件注入消息),面板 sessionsOf 取最早一条作为
32
+ * title。不作为独立存储字段,走现有事件通路,重启后自然恢复。
33
+ */
34
+ function handleSessionEvent(session, event, record) {
35
+ if (event === null || typeof event !== 'object' || event.type !== 'user/message') return
36
+ const message = event.data
37
+ if (isPluginMessage(message)) return
38
+ const sessionId = session?.id
39
+ if (typeof sessionId !== 'string' || sessionId === '') return
40
+ const text = userTextOf(message)
41
+ if (text === '') return
42
+ record({ type: 'user_message', sessionId, data: { text: truncate(text) } })
43
+ }
44
+
45
+ /** 是否为插件注入的消息(非真实用户输入,不作为标题)。 */
46
+ function isPluginMessage(message) {
47
+ const source = message?.source
48
+ return source !== null && typeof source === 'object' && source.kind === 'plugin'
49
+ }
50
+
51
+ /** 从 user message 提取文本(content 中全部 text block 拼接)。 */
52
+ function userTextOf(message) {
53
+ if (message === null || typeof message !== 'object') return ''
54
+ const content = message.content
55
+ if (!Array.isArray(content)) return ''
56
+ const parts = []
57
+ for (const block of content) {
58
+ if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
59
+ parts.push(block.text)
60
+ }
61
+ }
62
+ return parts.join(' ').trim()
63
+ }
64
+
27
65
  /** agent/status → agent_status 事件(含顶层/子代理标记)。 */
28
66
  function handleStatus(payload, record) {
29
67
  const agent = payload?.agent
package/lib/client.js CHANGED
@@ -50,6 +50,9 @@ const strings = {
50
50
  resourceMem: () => (isZh() ? '内存' : 'Memory'),
51
51
  gitTitle: () => (isZh() ? 'Git 工具' : 'Git Tools'),
52
52
  allSessions: () => (isZh() ? '全部会话' : 'All sessions'),
53
+ // ── 会话下拉可读性(issue #1xx:只能看到 UUID)────────────────────────
54
+ eventCount: (n) => (isZh() ? `${n} 事件` : `${n} events`),
55
+ sessionFallback: (shortId) => (isZh() ? `会话 ${shortId}` : `session ${shortId}`),
53
56
  filterAll: () => (isZh() ? '全部' : 'All'),
54
57
  filterStatus: () => (isZh() ? '状态' : 'Status'),
55
58
  filterLlm: () => (isZh() ? '模型流' : 'LLM'),
@@ -907,6 +910,18 @@ async function loadReplayData(selected, currentSession, setters) {
907
910
  }
908
911
  }
909
912
 
913
+ /** 下拉选项文案:可读标题(首条用户消息)优先,无标题回退 UUID 短显;
914
+ * 附加事件数与时间,用户一眼看出"哪个对话"。 */
915
+ function sessionOptionLabel(s) {
916
+ const title = typeof s.title === 'string' && s.title !== '' ? s.title : strings.sessionFallback(shortId(s.sessionId))
917
+ return `${title} · ${strings.eventCount(s.count)}`
918
+ }
919
+
920
+ /** 会话 id 短显示(UUID 取前 8 位)。 */
921
+ function shortId(sessionId) {
922
+ return typeof sessionId === 'string' && sessionId.length > 8 ? `${sessionId.slice(0, 8)}…` : sessionId || ''
923
+ }
924
+
910
925
  /** 工具栏:会话选择 + 手动刷新 + 类型过滤。 */
911
926
  function ReplayToolbar({ sessions, selected, onSelect, filter, onFilter, onRefresh }) {
912
927
  return createElement(
@@ -925,7 +940,9 @@ function ReplayToolbar({ sessions, selected, onSelect, filter, onFilter, onRefre
925
940
  },
926
941
  sessions.length === 0
927
942
  ? createElement('option', { value: '' }, strings.allSessions())
928
- : sessions.map((s) => createElement('option', { key: s.sessionId, value: s.sessionId }, s.sessionId)),
943
+ : sessions.map((s) =>
944
+ createElement('option', { key: s.sessionId, value: s.sessionId }, sessionOptionLabel(s)),
945
+ ),
929
946
  ),
930
947
  createElement(
931
948
  'button',
@@ -1050,7 +1067,10 @@ function useResourceState(visible) {
1050
1067
  if (!visible) return undefined
1051
1068
  let alive = true
1052
1069
  const tick = () => {
1053
- if (alive) apiJson('/observability/api/resources').then(setResource).catch(() => {})
1070
+ if (alive)
1071
+ apiJson('/observability/api/resources')
1072
+ .then(setResource)
1073
+ .catch(() => {})
1054
1074
  }
1055
1075
  tick()
1056
1076
  const timer = setInterval(tick, RESOURCE_POLL_MS)
@@ -1085,8 +1105,14 @@ function ResourcePanel({ resource }) {
1085
1105
  'div',
1086
1106
  { className: 'dsh-my-observability-resource-grid' },
1087
1107
  createElement(ResourceMetric, { label: strings.resourceFile(), value: fmtResourceBytes(resource.fileBytes) }),
1088
- createElement(ResourceMetric, { label: strings.resourceRate(), value: `${fmtResourceBytes(resource.writeRateBytesPerHour)}/h` }),
1089
- createElement(ResourceMetric, { label: strings.resourceCpu(), value: `${Math.round(resource.cpuPercent ?? 0)}%` }),
1108
+ createElement(ResourceMetric, {
1109
+ label: strings.resourceRate(),
1110
+ value: `${fmtResourceBytes(resource.writeRateBytesPerHour)}/h`,
1111
+ }),
1112
+ createElement(ResourceMetric, {
1113
+ label: strings.resourceCpu(),
1114
+ value: `${Math.round(resource.cpuPercent ?? 0)}%`,
1115
+ }),
1090
1116
  createElement(ResourceMetric, { label: strings.resourceMem(), value: fmtResourceBytes(resource.memoryBytes) }),
1091
1117
  ),
1092
1118
  alerts.length > 0
@@ -1386,7 +1412,10 @@ function useReplayDataState(props) {
1386
1412
  if (!visible) return undefined
1387
1413
  let alive = true
1388
1414
  const tick = () => {
1389
- if (alive) apiJson('/observability/api/resources').then(setResource).catch(() => {})
1415
+ if (alive)
1416
+ apiJson('/observability/api/resources')
1417
+ .then(setResource)
1418
+ .catch(() => {})
1390
1419
  }
1391
1420
  tick()
1392
1421
  const timer = setInterval(tick, RESOURCE_POLL_MS)
package/lib/index.js CHANGED
@@ -41,10 +41,21 @@ export function apply(ctx, config) {
41
41
  // ── 事件监听(只读观察;waterfall 一律透传 next())──────────────────
42
42
  attachAuditListeners(ctx, store.record)
43
43
 
44
- // ── 资源监控(15s 采样 CPU/内存/审计写入速率,阈值告警)─────────────
44
+ // ── 资源监控 + 降级看门狗(15s 采样 CPU/内存/审计写入速率;写放大/文件
45
+ // 超限连续触发时自动降级停落盘,资源回归后自动恢复——issue #127)────
45
46
  const monitor = createResourceMonitor(ctx, {
46
47
  intervalMs: config?.resourceIntervalMs,
47
48
  limits: config?.resourceLimits,
49
+ onDegrade: () => {
50
+ store.setPersistEnabled(false)
51
+ ctx.logger.warn(
52
+ '[dsh-my-observability] 资源看门狗:审计写入速率/文件大小连续超限,已暂停落盘(事件仍在内存,恢复后全量快照补齐)',
53
+ )
54
+ },
55
+ onRecover: () => {
56
+ store.setPersistEnabled(true)
57
+ ctx.logger.warn('[dsh-my-observability] 资源看门狗:写入速率回归正常,已恢复审计落盘')
58
+ },
48
59
  })
49
60
  monitor.start()
50
61
 
package/lib/parts/i18n.js CHANGED
@@ -18,6 +18,9 @@ const strings = {
18
18
  resourceMem: () => (isZh() ? '内存' : 'Memory'),
19
19
  gitTitle: () => (isZh() ? 'Git 工具' : 'Git Tools'),
20
20
  allSessions: () => (isZh() ? '全部会话' : 'All sessions'),
21
+ // ── 会话下拉可读性(issue #1xx:只能看到 UUID)────────────────────────
22
+ eventCount: (n) => (isZh() ? `${n} 事件` : `${n} events`),
23
+ sessionFallback: (shortId) => (isZh() ? `会话 ${shortId}` : `session ${shortId}`),
21
24
  filterAll: () => (isZh() ? '全部' : 'All'),
22
25
  filterStatus: () => (isZh() ? '状态' : 'Status'),
23
26
  filterLlm: () => (isZh() ? '模型流' : 'LLM'),
@@ -279,7 +279,10 @@ function useReplayDataState(props) {
279
279
  if (!visible) return undefined
280
280
  let alive = true
281
281
  const tick = () => {
282
- if (alive) apiJson('/observability/api/resources').then(setResource).catch(() => {})
282
+ if (alive)
283
+ apiJson('/observability/api/resources')
284
+ .then(setResource)
285
+ .catch(() => {})
283
286
  }
284
287
  tick()
285
288
  const timer = setInterval(tick, RESOURCE_POLL_MS)
@@ -194,6 +194,18 @@ async function loadReplayData(selected, currentSession, setters) {
194
194
  }
195
195
  }
196
196
 
197
+ /** 下拉选项文案:可读标题(首条用户消息)优先,无标题回退 UUID 短显;
198
+ * 附加事件数与时间,用户一眼看出"哪个对话"。 */
199
+ function sessionOptionLabel(s) {
200
+ const title = typeof s.title === 'string' && s.title !== '' ? s.title : strings.sessionFallback(shortId(s.sessionId))
201
+ return `${title} · ${strings.eventCount(s.count)}`
202
+ }
203
+
204
+ /** 会话 id 短显示(UUID 取前 8 位)。 */
205
+ function shortId(sessionId) {
206
+ return typeof sessionId === 'string' && sessionId.length > 8 ? `${sessionId.slice(0, 8)}…` : sessionId || ''
207
+ }
208
+
197
209
  /** 工具栏:会话选择 + 手动刷新 + 类型过滤。 */
198
210
  function ReplayToolbar({ sessions, selected, onSelect, filter, onFilter, onRefresh }) {
199
211
  return createElement(
@@ -212,7 +224,9 @@ function ReplayToolbar({ sessions, selected, onSelect, filter, onFilter, onRefre
212
224
  },
213
225
  sessions.length === 0
214
226
  ? createElement('option', { value: '' }, strings.allSessions())
215
- : sessions.map((s) => createElement('option', { key: s.sessionId, value: s.sessionId }, s.sessionId)),
227
+ : sessions.map((s) =>
228
+ createElement('option', { key: s.sessionId, value: s.sessionId }, sessionOptionLabel(s)),
229
+ ),
216
230
  ),
217
231
  createElement(
218
232
  'button',
@@ -15,7 +15,10 @@ function useResourceState(visible) {
15
15
  if (!visible) return undefined
16
16
  let alive = true
17
17
  const tick = () => {
18
- if (alive) apiJson('/observability/api/resources').then(setResource).catch(() => {})
18
+ if (alive)
19
+ apiJson('/observability/api/resources')
20
+ .then(setResource)
21
+ .catch(() => {})
19
22
  }
20
23
  tick()
21
24
  const timer = setInterval(tick, RESOURCE_POLL_MS)
@@ -50,8 +53,14 @@ function ResourcePanel({ resource }) {
50
53
  'div',
51
54
  { className: 'dsh-my-observability-resource-grid' },
52
55
  createElement(ResourceMetric, { label: strings.resourceFile(), value: fmtResourceBytes(resource.fileBytes) }),
53
- createElement(ResourceMetric, { label: strings.resourceRate(), value: `${fmtResourceBytes(resource.writeRateBytesPerHour)}/h` }),
54
- createElement(ResourceMetric, { label: strings.resourceCpu(), value: `${Math.round(resource.cpuPercent ?? 0)}%` }),
56
+ createElement(ResourceMetric, {
57
+ label: strings.resourceRate(),
58
+ value: `${fmtResourceBytes(resource.writeRateBytesPerHour)}/h`,
59
+ }),
60
+ createElement(ResourceMetric, {
61
+ label: strings.resourceCpu(),
62
+ value: `${Math.round(resource.cpuPercent ?? 0)}%`,
63
+ }),
55
64
  createElement(ResourceMetric, { label: strings.resourceMem(), value: fmtResourceBytes(resource.memoryBytes) }),
56
65
  ),
57
66
  alerts.length > 0
@@ -1,42 +1,52 @@
1
1
  /**
2
- * dsh-my-observability — 资源采样监控。
2
+ * dsh-my-observability — 资源采样监控 + 降级看门狗。
3
3
  *
4
4
  * 每 intervalMs(默认 15s)采样本进程 CPU/内存与审计文件大小/写入速率,
5
5
  * 保留最近 MAX_HISTORY 个样本(ring buffer),并做阈值评估(resource-rules)。
6
6
  * 用途:让「写放大/资源超限」在运行期当小时可见(9/2 事故复盘结论:
7
7
  * 15 小时 300GB 写入零监控是事故未被及时发现的主因)。
8
8
  *
9
+ * 降级看门狗(issue #127 资源占用防护):关键阈值(写放大/文件字节)连续
10
+ * DEGRADE_CONFIRM_COUNT 次超限 → 触发 onDegrade 回调(宿主降级:停落盘等);
11
+ * 连续 RECOVER_CONFIRM_COUNT 次正常 → 触发 onRecover 回调(宿主恢复 + 全量快照)。
12
+ * 判定为纯函数(resource-rules.shouldEnterDegrade/shouldExitDegrade),可单测。
13
+ *
9
14
  * 采样自身开销:15s 一次 process.cpuUsage/memoryUsage + fs.stat(<0.01% CPU、
10
15
  * 零分配大对象),远低于「监控不能放大被监控对象」的护栏(resource-budget-review)。
11
16
  */
12
17
  import { statSync } from 'node:fs'
13
- import { evaluateResourceAlerts, DEFAULT_LIMITS } from './resource-rules.js'
18
+ import { evaluateResourceAlerts, shouldEnterDegrade, shouldExitDegrade, DEFAULT_LIMITS } from './resource-rules.js'
14
19
  import { jsonlFile } from './store-persist.js'
15
20
 
16
21
  const DEFAULT_INTERVAL_MS = 15000
17
22
  const MAX_HISTORY = 60
18
23
 
19
- /** 创建资源监控器:{ sample, start, stop }。options: intervalMs / limits。 */
24
+ /** 创建资源监控器:{ sample, start, stop }。options: intervalMs / limits / onDegrade / onRecover。 */
20
25
  export function createResourceMonitor(ctx, options = {}) {
21
- const intervalMs = Number.isFinite(options.intervalMs) && options.intervalMs > 0 ? options.intervalMs : DEFAULT_INTERVAL_MS
26
+ const intervalMs =
27
+ Number.isFinite(options.intervalMs) && options.intervalMs > 0 ? options.intervalMs : DEFAULT_INTERVAL_MS
22
28
  const limits = { ...DEFAULT_LIMITS, ...(options.limits ?? {}) }
29
+ const onDegrade = typeof options.onDegrade === 'function' ? options.onDegrade : null
30
+ const onRecover = typeof options.onRecover === 'function' ? options.onRecover : null
23
31
  const state = {
24
32
  timer: null,
25
33
  file: jsonlFile(),
26
34
  lastSample: null,
27
35
  lastCpu: process.cpuUsage(),
28
36
  history: [],
37
+ degraded: false,
29
38
  }
30
39
  const monitor = {
31
- sample: () => sample(state, limits),
40
+ sample: () => sample(state, limits, onDegrade, onRecover),
32
41
  start: () => startMonitor(state, intervalMs, monitor),
33
42
  stop: () => stopMonitor(state),
43
+ isDegraded: () => state.degraded,
34
44
  }
35
45
  return monitor
36
46
  }
37
47
 
38
- /** 采样一次:CPU 使用率(窗口内 user+sys)/RSS/审计文件字节/写入速率 + 告警。 */
39
- function sample(state, limits) {
48
+ /** 采样一次:CPU 使用率(窗口内 user+sys)/RSS/审计文件字节/写入速率 + 告警 + 降级判定。 */
49
+ function sample(state, limits, onDegrade, onRecover) {
40
50
  const now = Date.now()
41
51
  const cpu = process.cpuUsage()
42
52
  const cpuDelta = cpu.user - state.lastCpu.user + (cpu.system - state.lastCpu.system) // µs
@@ -60,10 +70,32 @@ function sample(state, limits) {
60
70
  state.history.push(sample)
61
71
  if (state.history.length > MAX_HISTORY) state.history.splice(0, state.history.length - MAX_HISTORY)
62
72
  state.lastSample = sample
63
- return { ...sample, history: [...state.history], alerts: evaluateResourceAlerts(sample, limits) }
73
+ updateDegradeState(state, limits, onDegrade, onRecover)
74
+ return {
75
+ ...sample,
76
+ history: [...state.history],
77
+ alerts: evaluateResourceAlerts(sample, limits),
78
+ degraded: state.degraded,
79
+ }
64
80
  }
65
81
  state.lastSample = { time: now, fileBytes, memoryBytes, cpuPercent: 0, writeRateBytesPerHour: 0 }
66
- return { ...state.lastSample, history: [...state.history], alerts: [] }
82
+ return { ...state.lastSample, history: [...state.history], alerts: [], degraded: state.degraded }
83
+ }
84
+
85
+ /**
86
+ * 降级状态机:未降级且连续超限 → 进入降级(回调);已降级且连续正常 → 退出降级(回调)。
87
+ * 判定纯函数见 resource-rules.js;本函数只持有状态并触发宿主回调。
88
+ */
89
+ function updateDegradeState(state, limits, onDegrade, onRecover) {
90
+ if (!state.degraded) {
91
+ if (shouldEnterDegrade(state.history, limits)) {
92
+ state.degraded = true
93
+ onDegrade?.()
94
+ }
95
+ } else if (shouldExitDegrade(state.history, limits)) {
96
+ state.degraded = false
97
+ onRecover?.()
98
+ }
67
99
  }
68
100
 
69
101
  /** 启动周期采样(幂等)。 */
@@ -60,6 +60,43 @@ export function evaluateResourceAlerts(sample, limits = DEFAULT_LIMITS) {
60
60
  return alerts
61
61
  }
62
62
 
63
+ /** 连续多少次采样超限(关键阈值)判定进入降级(模块内默认值)。 */
64
+ const DEGRADE_CONFIRM_COUNT = 3
65
+
66
+ /** 连续多少次采样正常判定退出降级(模块内默认值)。 */
67
+ const RECOVER_CONFIRM_COUNT = 3
68
+
69
+ /**
70
+ * 关键阈值判定:磁盘写放大风险主导(write-rate / file-size 任一超限即记超限)。
71
+ * CPU/内存超限告警但不触发落盘降级(避免误伤正常大请求峰值)。
72
+ */
73
+ function isCriticalOverLimit(sample, limits = DEFAULT_LIMITS) {
74
+ return (
75
+ (sample.writeRateBytesPerHour ?? 0) > limits.writeRateBytesPerHour || (sample.fileBytes ?? 0) > limits.fileBytes
76
+ )
77
+ }
78
+
79
+ /**
80
+ * 降级判定(纯函数):最近 confirmCount 个样本**全部**关键阈值超限 → 进入降级。
81
+ * 历史样本不足 confirmCount 时不降级(冷启动保护)。
82
+ * 返回布尔(shouldEnterDegrade)。
83
+ */
84
+ export function shouldEnterDegrade(history, limits = DEFAULT_LIMITS, confirmCount = DEGRADE_CONFIRM_COUNT) {
85
+ const recent = history.slice(-confirmCount)
86
+ if (recent.length < confirmCount) return false
87
+ return recent.every((sample) => isCriticalOverLimit(sample, limits))
88
+ }
89
+
90
+ /**
91
+ * 恢复判定(纯函数):最近 confirmCount 个样本**全部**关键阈值正常 → 退出降级。
92
+ * 历史样本不足 confirmCount 时不恢复(避免刚降级立即恢复抖动)。
93
+ */
94
+ export function shouldExitDegrade(history, limits = DEFAULT_LIMITS, confirmCount = RECOVER_CONFIRM_COUNT) {
95
+ const recent = history.slice(-confirmCount)
96
+ if (recent.length < confirmCount) return false
97
+ return recent.every((sample) => !isCriticalOverLimit(sample, limits))
98
+ }
99
+
63
100
  function fmtMB(bytes) {
64
101
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
65
102
  }
@@ -24,7 +24,7 @@ export const MAX_TOTAL_EVENTS = 20000
24
24
  export const COMPACT_LINES = 5000
25
25
 
26
26
  /** 数据根目录:$DSH_HOME(fallback 家目录)。 */
27
- export function dataHome() {
27
+ function dataHome() {
28
28
  const home = process.env.DSH_HOME
29
29
  return typeof home === 'string' && home !== '' ? home : homedir()
30
30
  }
@@ -40,7 +40,7 @@ export function legacyFile() {
40
40
  }
41
41
 
42
42
  /** 事件结构校验(时间/会话/类型为合理形态)。 */
43
- export function isValidEvent(event) {
43
+ function isValidEvent(event) {
44
44
  return (
45
45
  event !== null &&
46
46
  typeof event === 'object' &&
@@ -65,7 +65,9 @@ export function normalizeLoaded(bySession) {
65
65
  let total = 0
66
66
  for (const bucket of Object.values(sessionBuckets)) total += bucket.events.length
67
67
  if (total > MAX_TOTAL_EVENTS) {
68
- const ordered = Object.keys(sessionBuckets).sort((a, b) => firstTimeOf(sessionBuckets[a]) - firstTimeOf(sessionBuckets[b]))
68
+ const ordered = Object.keys(sessionBuckets).sort(
69
+ (a, b) => firstTimeOf(sessionBuckets[a]) - firstTimeOf(sessionBuckets[b]),
70
+ )
69
71
  for (const sessionId of ordered) {
70
72
  if (total <= MAX_TOTAL_EVENTS) break
71
73
  total -= sessionBuckets[sessionId].events.length
package/lib/store.js CHANGED
@@ -53,12 +53,14 @@ export function createStore(ctx) {
53
53
  compactTimer: null,
54
54
  compacting: false,
55
55
  migrated: false,
56
+ persistEnabled: true,
56
57
  dirtyChain: Promise.resolve(),
57
58
  }
58
59
  store.record = (event) => record(handle, event)
59
60
  store.events = (sessionId, type, limit) => eventsOf(handle, sessionId, type, limit)
60
61
  store.sessions = () => sessionsOf(handle)
61
62
  store.count = () => countOf(handle)
63
+ store.setPersistEnabled = (enabled) => setPersistEnabled(handle, enabled)
62
64
  store.dispose = () => dispose(handle)
63
65
  void loadPersisted(handle.file, handle.legacy).then((result) => onLoaded(handle, result))
64
66
  return store
@@ -76,14 +78,34 @@ function record(handle, event) {
76
78
  return item
77
79
  }
78
80
 
79
- /** 事件入内存桶 + 排队待落盘行(回放与运行时共用,保证回放也落盘)。 */
81
+ /** 事件入内存桶 + 排队待落盘行(回放与运行时共用,保证回放也落盘)。
82
+ * 降级(persistEnabled=false)时事件照常入内存桶(有 FIFO 上限保护),
83
+ * 但不排队落盘行、不触发 flush——写盘完全停止,恢复时全量快照补齐。 */
80
84
  function enqueueRecord(handle, item) {
81
85
  appendEvent(handle, item)
86
+ if (!handle.persistEnabled) return
82
87
  handle.lineQueue.push(JSON.stringify(item))
83
88
  handle.queuedLines += 1
84
89
  if (handle.queuedLines >= COMPACT_LINES) scheduleCompact(handle)
85
90
  }
86
91
 
92
+ /**
93
+ * 落盘开关(资源看门狗降级用):
94
+ * - false(降级):停止落盘——清空待写行(内存 state 仍在,不丢),停 flush。
95
+ * - true(恢复):立即 compact 全量快照(内存=真相,补齐降级窗口事件),恢复增量。
96
+ */
97
+ function setPersistEnabled(handle, enabled) {
98
+ if (handle.persistEnabled === enabled) return
99
+ handle.persistEnabled = enabled
100
+ if (handle.flushTimer !== null) {
101
+ clearTimeout(handle.flushTimer)
102
+ handle.flushTimer = null
103
+ }
104
+ handle.lineQueue = []
105
+ handle.queuedLines = 0
106
+ if (enabled) compactNow(handle)
107
+ }
108
+
87
109
  /** 全部会话事件:合并各会话并按时间正序(sessionId='*')。 */
88
110
  function eventsAllOf(handle, type, limit) {
89
111
  let all = []
@@ -112,7 +134,7 @@ function eventsOf(handle, sessionId, type, limit) {
112
134
  return capped.map((event) => ({ ...event }))
113
135
  }
114
136
 
115
- /** 有审计事件的会话列表(按最后活动时间倒序,含事件数)。 */
137
+ /** 有审计事件的会话列表(按最后活动时间倒序,含事件数与可读标题)。 */
116
138
  function sessionsOf(handle) {
117
139
  const entries = Object.entries(handle.store.state.bySession)
118
140
  const list = entries
@@ -120,12 +142,23 @@ function sessionsOf(handle) {
120
142
  sessionId,
121
143
  count: bucket.events.length,
122
144
  lastTime: bucket.events.length > 0 ? bucket.events[bucket.events.length - 1].time : 0,
145
+ title: sessionTitleOf(bucket),
123
146
  }))
124
147
  .filter((entry) => entry.count > 0)
125
148
  list.sort((a, b) => b.lastTime - a.lastTime)
126
149
  return list
127
150
  }
128
151
 
152
+ /** 会话标题:最早一条 user_message 事件的文本(无则空串,面板回退 UUID 短显)。 */
153
+ function sessionTitleOf(bucket) {
154
+ for (const event of bucket.events) {
155
+ if (event.type === 'user_message' && typeof event.data?.text === 'string' && event.data.text !== '') {
156
+ return event.data.text
157
+ }
158
+ }
159
+ return ''
160
+ }
161
+
129
162
  /** 全部会话事件总数(O(1) 计数)。 */
130
163
  function countOf(handle) {
131
164
  return handle.total
@@ -209,12 +242,10 @@ function scheduleFlush(handle) {
209
242
  }
210
243
 
211
244
  function flushNow(handle) {
212
- if (handle.lineQueue.length === 0) return
245
+ if (handle.lineQueue.length === 0 || !handle.persistEnabled) return
213
246
  const text = `${handle.lineQueue.join('\n')}\n`
214
247
  handle.lineQueue = []
215
- handle.dirtyChain = handle.dirtyChain.then(() =>
216
- appendLines(handle.file, text, handle.ctx.logger, PREFIX),
217
- )
248
+ handle.dirtyChain = handle.dirtyChain.then(() => appendLines(handle.file, text, handle.ctx.logger, PREFIX))
218
249
  }
219
250
 
220
251
  /** 防抖紧凑调度(合并短时间多次触发)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-observability",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "DSH 可观测性 + Git 工程工具插件:会话轨迹回放(时间轴)、事件审计(agent 行为记录)、结构化 Git 提交(Conventional Commits)、增量 diff 审查(提交前审查)。DSH web plugin: trajectory replay timeline, event audit log, structured git commits, pre-commit incremental diff review.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",