dsh-activity-pane 0.7.0 → 0.9.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/src/host.mjs CHANGED
@@ -1,4 +1,5 @@
1
- // dsh-activity-pane 宿主侧:完成确认与错误提醒状态(R-01-002/AC-03、AC-05、AC-10~AC-13、R-01-010/AC-06,C-030、C-043)。
1
+ // dsh-activity-pane 宿主侧:完成确认与错误提醒状态(R-01-002/AC-03、AC-05、AC-10~AC-13、R-01-010/AC-06,C-030、C-043),
2
+ // 以及会话累计运行时长记账(R-01-020,C-074)。
2
3
  //
3
4
  // 职责:
4
5
  // 1. 订阅 `session/event` 的 `turn/end`,把事件顶层 `time` 登记为会话的 `lastTurnEnd`,
@@ -7,9 +8,17 @@
7
8
  // 2. `POST /dsh-activity-pane/api/ack` 写回 `ackedAt`;
8
9
  // 3. `GET /dsh-activity-pane/api/acks` 全量快照;`GET /dsh-activity-pane/api/acks/stream` SSE 推送
9
10
  // (连接即发全量、变更即广播)。
11
+ // 4. 订阅 `session/event` 按回合与等待边界(`approval/asked`–`decided`、`ask_user_question`
12
+ // 的 `tool/call`–`tool/result`)配对累计每会话运行过程时长(R-01-020):
13
+ // 持久化于独立 domain 表 `turn_stats`(`{ busyMs, openTurnStart, openWaitStart,
14
+ // openWaitKind, openWaitId, waitedMs, watermarkSeq }`),无记录/水位缺口/水位超前会话经
15
+ // `sessionQuery.listEvents` 懒回填(统一 reconcileTurnStats 收敛,启动扫描强制结算
16
+ // 残留开放回合),经 `GET /api/busy` 全量快照与 `/busy/stream` SSE 只读下发,无写回路径。
10
17
  //
11
18
  // 持久化:storageDomain 声明式 domain 表 `acks`(sessionId → { lastTurnEnd, lastTurnEndKind,
12
- // lastTurnEndError, ackedAt })。
19
+ // lastTurnEndError, ackedAt })与 `dsh_activity_pane_turns` 表 `turn_stats`(sessionId →
20
+ // { busyMs, openTurnStart, watermarkSeq })——domain 无迁移机制(version 不同在 open 时
21
+ // 拒绝),新增表必须落在新 domain,不动既有 acks domain。
13
22
  // 设计约束(C-030):不写会话日志、不依赖 sessionProjections、不引入客户端轮询;
14
23
  // 完成提醒成立 = 主会话 && 非 running && 无阻塞等待 && 非委托周期 && lastTurnEnd > ackedAt,
15
24
  // 错误提醒成立 = 主会话 && 非 running && 无阻塞等待 && 非委托周期 && lastTurnEndKind === 'error'
@@ -17,10 +26,10 @@
17
26
 
18
27
  import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
19
28
  import { z } from 'zod'
20
- import { truncateErrorNote } from './core.mjs'
29
+ import { applyTurnEventToStats, isBusyBoundaryEvent, reconcileTurnStats, turnStatsEqual, turnStatsFrom, truncateErrorNote } from './core.mjs'
21
30
 
22
31
  export const name = 'dsh-activity-pane'
23
- export const inject = ['storageDomain', 'webServer']
32
+ export const inject = ['storageDomain', 'webServer', 'sessionQuery', 'connection']
24
33
 
25
34
  const API_PATH = '/dsh-activity-pane/api'
26
35
 
@@ -46,6 +55,35 @@ const domainSpec = defineDomain({
46
55
  },
47
56
  })
48
57
 
58
+ /** 每会话累计运行时长记账(R-01-020):busyMs 运行过程时长累计(null=尚无有效计时)、
59
+ * openTurnStart 当前开放回合起点(无开放回合为 null)、openWaitStart/openWaitKind 当前
60
+ * 未配对等待区间(回合内阻塞等待:approval/question)、waitedMs 本回合已配对等待时长
61
+ * 累计、watermarkSeq 实时登记已覆盖的最大事件 seq(回填与实时登记以水位衔接,seq ≤
62
+ * watermark 的事件不重复应用)、watermarkTime 水位处事件的时刻(实时登记据此识别 seq
63
+ * 空间重编:低 seq 事件携带比水位更新的时刻,增量口径失效,须全量重放)。全部字段
64
+ * 可选/可空。 */
65
+ const turnStatRecord = z.object({
66
+ busyMs: z.number().nullable().optional(),
67
+ openTurnStart: z.number().nullable().optional(),
68
+ openWaitStart: z.number().nullable().optional(),
69
+ openWaitKind: z.string().nullable().optional(),
70
+ openWaitId: z.string().nullable().optional(),
71
+ waitedMs: z.number().nullable().optional(),
72
+ watermarkSeq: z.number().nullable().optional(),
73
+ watermarkTime: z.number().nullable().optional(),
74
+ })
75
+
76
+ /** 独立 domain:dsh-storage-domain 无迁移机制(version 不同在 open 时拒绝),新增表落新 domain,
77
+ * 不动既有 acks domain 的 version 1 medium(C-074)。 */
78
+ const turnDomainSpec = defineDomain({
79
+ name: 'dsh_activity_pane_turns',
80
+ version: 1,
81
+ global: { schema: z.object({}), initial: {} },
82
+ tables: {
83
+ turn_stats: domainTable(turnStatRecord),
84
+ },
85
+ })
86
+
49
87
  /** 从请求流读取 JSON body;空 body 返回 null。 */
50
88
  function readJsonBody(req) {
51
89
  return new Promise((resolve) => {
@@ -70,16 +108,105 @@ export function apply(ctx) {
70
108
  resolveDomain = resolve
71
109
  })
72
110
  let acks = null // KvTable: sessionId -> { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt }
111
+ let turnStats = null // KvTable: sessionId -> { busyMs, openTurnStart, watermarkSeq }
73
112
  /** SSE 连接集合:广播时逐个写 `event: state`。 */
74
113
  const streamClients = new Set()
114
+ const busyStreamClients = new Set()
115
+ /** 懒回填单飞:每会话至多一个在途回填,重复触发共享同一 promise(R-01-020/AC-05)。 */
116
+ const backfills = new Map()
75
117
 
76
118
  ctx.inject(['storageDomain'], async (domainCtx) => {
77
119
  const domain = await domainCtx.storageDomain.open(domainSpec)
120
+ const turnDomain = await domainCtx.storageDomain.open(turnDomainSpec)
78
121
  ctx.effect(() => () => domain.close(), 'dsh-activity-pane: domainClose')
122
+ ctx.effect(() => () => turnDomain.close(), 'dsh-activity-pane: turnDomainClose')
79
123
  acks = domain.table('acks')
124
+ turnStats = turnDomain.table('turn_stats')
80
125
  resolveDomain()
126
+ // 启动扫描(R-01-020/AC-04):宿主重启后不存在仍在运行的回合——残留开放回合的
127
+ // 记账按日志最后事件时刻强制结算关闭,否则总耗时按 now − openTurnStart 无限增长。
128
+ // 无 watermarkTime 的存量记录(本字段引入前写入)强制全量重放复核一次——无论重编
129
+ // 后日志 seq 是否已超越旧水位(部分重叠重编)都能收敛,重写后带上该字段、不再
130
+ // 重复扫描;此后重编由实时登记的重编检测承接。
131
+ for (const [id, record] of turnStats.entries()) {
132
+ const closeOpenTurn = record?.openTurnStart != null
133
+ const legacy = record?.watermarkTime == null
134
+ if (closeOpenTurn || legacy) ensureTurnStatsFresh(id, { closeOpenTurn, forceFresh: legacy })
135
+ }
81
136
  })
82
137
 
138
+ /** 全量 busy 快照:{ [sessionId]: { busyMs, openTurnStart, waitedMs, openWaitStart } }
139
+ * (水位与等待种类为宿主内部记账,不下发)。 */
140
+ function busySnapshot() {
141
+ const out = {}
142
+ if (turnStats === null) return out
143
+ for (const [id, record] of turnStats.entries()) {
144
+ out[id] = {
145
+ busyMs: record?.busyMs ?? null,
146
+ openTurnStart: record?.openTurnStart ?? null,
147
+ waitedMs: record?.waitedMs ?? null,
148
+ openWaitStart: record?.openWaitStart ?? null,
149
+ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ /** 向全部 busy SSE 连接广播当前全量状态。 */
155
+ function broadcastBusy() {
156
+ const data = `event: state\ndata: ${JSON.stringify(busySnapshot())}\n\n`
157
+ for (const res of busyStreamClients) {
158
+ try {
159
+ res.write(data)
160
+ } catch {
161
+ busyStreamClients.delete(res)
162
+ }
163
+ }
164
+ }
165
+
166
+ /** 懒回填(R-01-020/AC-05,C-074):经 sessionQuery.listEvents 读取会话事件,统一经
167
+ * reconcileTurnStats 收敛——无记录、水位非法或水位超前于日志最大 seq(事件 seq 空间
168
+ * 被重编,如 dsh 0.1.5 V3 迁移)的会话自空状态全量重放;已有记录会话仅增量应用
169
+ * seq > watermarkSeq 的事件(宿主离线期间经其它入口发生的回合),以水位衔接保证
170
+ * 不重复、不遗漏。forceFresh:调用侧已证实水位口径失效(实时登记识别出 seq 空间
171
+ * 重编,或存量记录尚未携带 watermarkTime)时丢弃已有记账、强制从空状态全量重放;
172
+ * 在途回填不得吞掉该选项——命中时在其完成后补一次强制重放。closeOpenTurn(启动
173
+ * 扫描):重放后仍开放的回合按日志最后事件时刻强制结算。读取失败保留已有记账,
174
+ * 随下次请求重试;会话不存在/已删除时静默跳过。 */
175
+ function ensureTurnStatsFresh(id, { closeOpenTurn = false, forceFresh = false } = {}) {
176
+ if (backfills.has(id)) {
177
+ const inflight = backfills.get(id)
178
+ if (!forceFresh) return inflight
179
+ return inflight.then(() => ensureTurnStatsFresh(id, { closeOpenTurn, forceFresh: true }))
180
+ }
181
+ const promise = new Promise((resolve) => {
182
+ ctx.inject(['sessionQuery'], (sessionQuery) => {
183
+ resolve(sessionQuery)
184
+ })
185
+ }).then(async (sessionQuery) => {
186
+ try {
187
+ await domainReady
188
+ const current = forceFresh ? null : turnStats.get(id) ?? null
189
+ const records = await sessionQuery.listEvents(id)
190
+ const next = reconcileTurnStats(current, Array.isArray(records) ? records : [], { closeOpenTurn })
191
+ const changed =
192
+ forceFresh ||
193
+ current === null ||
194
+ next.watermarkSeq !== (current.watermarkSeq ?? null) ||
195
+ !turnStatsEqual(next, current)
196
+ if (changed) {
197
+ await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: next.watermarkSeq, watermarkTime: next.watermarkTime })
198
+ broadcastBusy()
199
+ }
200
+ } catch (error) {
201
+ ctx.logger?.warn?.(`dsh-activity-pane: busy 回填失败(${id}): ${String(error)}`)
202
+ } finally {
203
+ backfills.delete(id)
204
+ }
205
+ })
206
+ backfills.set(id, promise)
207
+ return promise
208
+ }
209
+
83
210
  /** 全量快照:{ [sessionId]: { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt } }。
84
211
  * acks 未就绪时返回空对象。旧记录缺新字段时按 null/undefined 下发(客户端判定恒安全)。 */
85
212
  function snapshot() {
@@ -132,10 +259,74 @@ export function apply(ctx) {
132
259
  }
133
260
  })
134
261
 
262
+ // 累计运行时长实时登记(R-01-020/AC-02、AC-04、AC-05、AC-07,C-074):turn/start–turn/end
263
+ // 配对记账,回合内阻塞等待经 approval/asked–decided 与 ask_user_question 的 tool/call–
264
+ // tool/result 配对扣除;completed/blocked/max-tokens/aborted/error 全部结束原因均计入;
265
+ // 事件按会话序提交,seq ≤ watermark 的事件幂等跳过;无效果的边界事件(如非提问工具的
266
+ // tool/call)不落盘、不广播;主/子统一登记,过滤由客户端判定。
267
+ // 表内无记录的会话先懒回填存量回合再应用实时事件——否则首个实时事件会以 null 建立记录、
268
+ // 存量回合永远失去补齐机会。
269
+ ctx.on('session/event', async (session, event) => {
270
+ const type = event?.type
271
+ if (!isBusyBoundaryEvent(type)) return
272
+ const seq = Number(event.seq)
273
+ if (!Number.isFinite(seq)) return
274
+ const id = String(session?.id ?? '')
275
+ if (id === '') return
276
+ try {
277
+ await domainReady
278
+ if (!turnStats.get(id)) {
279
+ await ensureTurnStatsFresh(id)
280
+ } else {
281
+ // 回填在途时排队等待其写入完成,再按水位应用实时事件,避免回填快照
282
+ // 覆盖(回退)实时事件的效果或造成缺口丢失(R-01-020/AC-04)。
283
+ const inflight = backfills.get(id)
284
+ if (inflight) await inflight
285
+ }
286
+ let current = turnStats.get(id)
287
+ if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) {
288
+ // 重编检测(R-01-020/AC-04):稳定 seq 空间内低水位事件的时刻不可能比水位处
289
+ // 事件更新;时刻反而更新说明事件 seq 空间被重编(如 dsh 0.1.5 V3 迁移),
290
+ // 持久化水位已失效,增量口径不再封死后续事件。无 watermarkTime 的存量记录
291
+ //(本字段引入前写入,其水位未经重编检测校验)同样不可信——两者统一丢弃旧
292
+ // 记账强制全量重放自愈;重放若仍覆盖不到本事件(日志写入滞后),落回下方
293
+ // 正常增量路径应用。
294
+ const time = Number(event.time)
295
+ if (!Number.isFinite(time)) return
296
+ if (Number.isFinite(current.watermarkTime) && time <= current.watermarkTime) return
297
+ await ensureTurnStatsFresh(id, { forceFresh: true })
298
+ current = turnStats.get(id)
299
+ if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) return
300
+ }
301
+ const next = applyTurnEventToStats(turnStatsFrom(current), event)
302
+ if (turnStatsEqual(next, current)) {
303
+ // 无效果事件(非提问工具调用、未配对的结算事件等):不落盘不广播,
304
+ // 水位不前移——重放路径对同类事件同样不改变记账状态,口径一致。
305
+ return
306
+ }
307
+ const time = Number(event.time)
308
+ // 与 reconcile 同口径:时刻无效时保留既有 watermarkTime 不前推(能走到此处的
309
+ // 必为有效果事件、时刻有效,null 分支仅作防御)。
310
+ await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: seq, watermarkTime: Number.isFinite(time) ? time : turnStatsFrom(current).watermarkTime ?? null })
311
+ broadcastBusy()
312
+ } catch (error) {
313
+ ctx.logger?.warn?.(`dsh-activity-pane: 回合统计登记失败(${id}): ${String(error)}`)
314
+ }
315
+ })
316
+
135
317
  // HTTP API(前缀挂载,handler 内按子路径分发)。
318
+ // A1-08:自定义 webServer 路由不继承宿主鉴权门——每个请求先过
319
+ // connection.requestRejection,401/403 直接拒绝,避免 acks/busy 读写通道
320
+ // 成为绕过 bootstrap token/signed cookie 的安全空洞。
136
321
  ctx.webServer.register({
137
322
  path: API_PATH,
138
323
  handler(req, res) {
324
+ const rejection = ctx.connection?.requestRejection?.(req)
325
+ if (rejection !== undefined) {
326
+ res.writeHead(rejection, { 'Content-Type': 'text/plain' })
327
+ res.end('unauthorized')
328
+ return
329
+ }
139
330
  const url = new URL(req.url || '/', 'http://dsh-activity-pane')
140
331
  const route = url.pathname.slice(API_PATH.length)
141
332
  const method = req.method || 'GET'
@@ -188,6 +379,34 @@ export function apply(ctx) {
188
379
  })
189
380
  return
190
381
  }
382
+ if (route === '/busy' && method === 'GET') {
383
+ // 只读下发:响应立即返回当前全量快照;查询参数 ids(逗号分隔会话 id)指定
384
+ // 客户端当前可见的主会话,对其中无记录者触发懒回填(fire-and-forget),
385
+ // 回填完成经 /busy/stream 广播推送(R-01-020/AC-05)。
386
+ const idsParam = url.searchParams.get('ids') ?? ''
387
+ for (const id of idsParam.split(',')) {
388
+ if (id !== '') ensureTurnStatsFresh(id)
389
+ }
390
+ res.writeHead(200, { 'Content-Type': 'application/json' })
391
+ res.end(JSON.stringify(busySnapshot()))
392
+ return
393
+ }
394
+ if (route === '/busy/stream' && method === 'GET') {
395
+ // SSE:连接即发全量快照,此后每次变更广播;浏览器 EventSource 自动重连,
396
+ // 重连时再收一次全量,状态必然收敛。
397
+ res.writeHead(200, {
398
+ 'Content-Type': 'text/event-stream',
399
+ 'Cache-Control': 'no-cache',
400
+ Connection: 'keep-alive',
401
+ })
402
+ const hello = `event: state\ndata: ${JSON.stringify(busySnapshot())}\n\n`
403
+ res.write(hello)
404
+ busyStreamClients.add(res)
405
+ const remove = () => busyStreamClients.delete(res)
406
+ req.on('close', remove)
407
+ res.on('close', remove)
408
+ return
409
+ }
191
410
  res.writeHead(404, { 'Content-Type': 'application/json' })
192
411
  res.end(JSON.stringify({ ok: false, error: 'not found' }))
193
412
  },
@@ -203,5 +422,13 @@ export function apply(ctx) {
203
422
  }
204
423
  }
205
424
  streamClients.clear()
425
+ for (const res of busyStreamClients) {
426
+ try {
427
+ res.end()
428
+ } catch {
429
+ /* 连接已断 */
430
+ }
431
+ }
432
+ busyStreamClients.clear()
206
433
  }
207
434
  }
@@ -84,8 +84,9 @@ export function scrollCardIntoView(scroll, card, behavior = "smooth") {
84
84
  return Number(scroll.scrollTop) !== currentTop;
85
85
  }
86
86
 
87
- /** 原生会话输入框(dsh-client-ui-conversation composer 的 textarea)。 */
88
- export const COMPOSER_SELECTOR = "textarea[data-phase]";
87
+ /** 原生会话输入框:dsh-client-ui-conversation 的 composer 输入面(Lexical contenteditable div,
88
+ * 宿主以 data-composer-input 显式标记;宿主 0.1.5 重写前为 textarea[data-phase])。 */
89
+ export const COMPOSER_SELECTOR = "[data-composer-input]";
89
90
 
90
91
  /**
91
92
  * 抑制切换会话后原生 composer 的自动聚焦(R-01-005/AC-01 移动端回归)。