dsh-activity-pane 0.7.0 → 0.8.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,32 @@ 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 的事件不重复应用)。全部字段可选/可空。 */
63
+ const turnStatRecord = z.object({
64
+ busyMs: z.number().nullable().optional(),
65
+ openTurnStart: z.number().nullable().optional(),
66
+ openWaitStart: z.number().nullable().optional(),
67
+ openWaitKind: z.string().nullable().optional(),
68
+ openWaitId: z.string().nullable().optional(),
69
+ waitedMs: z.number().nullable().optional(),
70
+ watermarkSeq: z.number().nullable().optional(),
71
+ })
72
+
73
+ /** 独立 domain:dsh-storage-domain 无迁移机制(version 不同在 open 时拒绝),新增表落新 domain,
74
+ * 不动既有 acks domain 的 version 1 medium(C-074)。 */
75
+ const turnDomainSpec = defineDomain({
76
+ name: 'dsh_activity_pane_turns',
77
+ version: 1,
78
+ global: { schema: z.object({}), initial: {} },
79
+ tables: {
80
+ turn_stats: domainTable(turnStatRecord),
81
+ },
82
+ })
83
+
49
84
  /** 从请求流读取 JSON body;空 body 返回 null。 */
50
85
  function readJsonBody(req) {
51
86
  return new Promise((resolve) => {
@@ -70,16 +105,90 @@ export function apply(ctx) {
70
105
  resolveDomain = resolve
71
106
  })
72
107
  let acks = null // KvTable: sessionId -> { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt }
108
+ let turnStats = null // KvTable: sessionId -> { busyMs, openTurnStart, watermarkSeq }
73
109
  /** SSE 连接集合:广播时逐个写 `event: state`。 */
74
110
  const streamClients = new Set()
111
+ const busyStreamClients = new Set()
112
+ /** 懒回填单飞:每会话至多一个在途回填,重复触发共享同一 promise(R-01-020/AC-05)。 */
113
+ const backfills = new Map()
75
114
 
76
115
  ctx.inject(['storageDomain'], async (domainCtx) => {
77
116
  const domain = await domainCtx.storageDomain.open(domainSpec)
117
+ const turnDomain = await domainCtx.storageDomain.open(turnDomainSpec)
78
118
  ctx.effect(() => () => domain.close(), 'dsh-activity-pane: domainClose')
119
+ ctx.effect(() => () => turnDomain.close(), 'dsh-activity-pane: turnDomainClose')
79
120
  acks = domain.table('acks')
121
+ turnStats = turnDomain.table('turn_stats')
80
122
  resolveDomain()
123
+ // 启动扫描(R-01-020/AC-04):宿主重启后不存在仍在运行的回合——残留开放回合的
124
+ // 记账按日志最后事件时刻强制结算关闭,否则总耗时按 now − openTurnStart 无限增长。
125
+ for (const [id, record] of turnStats.entries()) {
126
+ if (record?.openTurnStart != null) ensureTurnStatsFresh(id, { closeOpenTurn: true })
127
+ }
81
128
  })
82
129
 
130
+ /** 全量 busy 快照:{ [sessionId]: { busyMs, openTurnStart, waitedMs, openWaitStart } }
131
+ * (水位与等待种类为宿主内部记账,不下发)。 */
132
+ function busySnapshot() {
133
+ const out = {}
134
+ if (turnStats === null) return out
135
+ for (const [id, record] of turnStats.entries()) {
136
+ out[id] = {
137
+ busyMs: record?.busyMs ?? null,
138
+ openTurnStart: record?.openTurnStart ?? null,
139
+ waitedMs: record?.waitedMs ?? null,
140
+ openWaitStart: record?.openWaitStart ?? null,
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ /** 向全部 busy SSE 连接广播当前全量状态。 */
147
+ function broadcastBusy() {
148
+ const data = `event: state\ndata: ${JSON.stringify(busySnapshot())}\n\n`
149
+ for (const res of busyStreamClients) {
150
+ try {
151
+ res.write(data)
152
+ } catch {
153
+ busyStreamClients.delete(res)
154
+ }
155
+ }
156
+ }
157
+
158
+ /** 懒回填(R-01-020/AC-05,C-074):经 sessionQuery.listEvents 读取会话事件,统一经
159
+ * reconcileTurnStats 收敛——无记录、水位非法或水位超前于日志最大 seq(事件 seq 空间
160
+ * 被重编,如 dsh 0.1.5 V3 迁移)的会话自空状态全量重放;已有记录会话仅增量应用
161
+ * seq > watermarkSeq 的事件(宿主离线期间经其它入口发生的回合),以水位衔接保证
162
+ * 不重复、不遗漏。closeOpenTurn(启动扫描):重放后仍开放的回合按日志最后事件
163
+ * 时刻强制结算。读取失败保留已有记账,随下次请求重试;会话不存在/已删除时静默跳过。 */
164
+ function ensureTurnStatsFresh(id, { closeOpenTurn = false } = {}) {
165
+ if (backfills.has(id)) return backfills.get(id)
166
+ const promise = new Promise((resolve) => {
167
+ ctx.inject(['sessionQuery'], (sessionQuery) => {
168
+ resolve(sessionQuery)
169
+ })
170
+ }).then(async (sessionQuery) => {
171
+ try {
172
+ await domainReady
173
+ const current = turnStats.get(id) ?? null
174
+ const records = await sessionQuery.listEvents(id)
175
+ const next = reconcileTurnStats(current, Array.isArray(records) ? records : [], { closeOpenTurn })
176
+ const changed =
177
+ current === null || next.watermarkSeq !== (current.watermarkSeq ?? null) || !turnStatsEqual(next, current)
178
+ if (changed) {
179
+ await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: next.watermarkSeq })
180
+ broadcastBusy()
181
+ }
182
+ } catch (error) {
183
+ ctx.logger?.warn?.(`dsh-activity-pane: busy 回填失败(${id}): ${String(error)}`)
184
+ } finally {
185
+ backfills.delete(id)
186
+ }
187
+ })
188
+ backfills.set(id, promise)
189
+ return promise
190
+ }
191
+
83
192
  /** 全量快照:{ [sessionId]: { lastTurnEnd, lastTurnEndKind, lastTurnEndError, ackedAt } }。
84
193
  * acks 未就绪时返回空对象。旧记录缺新字段时按 null/undefined 下发(客户端判定恒安全)。 */
85
194
  function snapshot() {
@@ -132,10 +241,58 @@ export function apply(ctx) {
132
241
  }
133
242
  })
134
243
 
244
+ // 累计运行时长实时登记(R-01-020/AC-02、AC-04、AC-05、AC-07,C-074):turn/start–turn/end
245
+ // 配对记账,回合内阻塞等待经 approval/asked–decided 与 ask_user_question 的 tool/call–
246
+ // tool/result 配对扣除;completed/blocked/max-tokens/aborted/error 全部结束原因均计入;
247
+ // 事件按会话序提交,seq ≤ watermark 的事件幂等跳过;无效果的边界事件(如非提问工具的
248
+ // tool/call)不落盘、不广播;主/子统一登记,过滤由客户端判定。
249
+ // 表内无记录的会话先懒回填存量回合再应用实时事件——否则首个实时事件会以 null 建立记录、
250
+ // 存量回合永远失去补齐机会。
251
+ ctx.on('session/event', async (session, event) => {
252
+ const type = event?.type
253
+ if (!isBusyBoundaryEvent(type)) return
254
+ const seq = Number(event.seq)
255
+ if (!Number.isFinite(seq)) return
256
+ const id = String(session?.id ?? '')
257
+ if (id === '') return
258
+ try {
259
+ await domainReady
260
+ if (!turnStats.get(id)) {
261
+ await ensureTurnStatsFresh(id)
262
+ } else {
263
+ // 回填在途时排队等待其写入完成,再按水位应用实时事件,避免回填快照
264
+ // 覆盖(回退)实时事件的效果或造成缺口丢失(R-01-020/AC-04)。
265
+ const inflight = backfills.get(id)
266
+ if (inflight) await inflight
267
+ }
268
+ const current = turnStats.get(id)
269
+ if (Number.isFinite(current?.watermarkSeq) && seq <= current.watermarkSeq) return
270
+ const next = applyTurnEventToStats(turnStatsFrom(current), event)
271
+ if (turnStatsEqual(next, current)) {
272
+ // 无效果事件(非提问工具调用、未配对的结算事件等):不落盘不广播,
273
+ // 水位不前移——重放路径对同类事件同样不改变记账状态,口径一致。
274
+ return
275
+ }
276
+ await turnStats.put(id, { ...turnStatsFrom(next), watermarkSeq: seq })
277
+ broadcastBusy()
278
+ } catch (error) {
279
+ ctx.logger?.warn?.(`dsh-activity-pane: 回合统计登记失败(${id}): ${String(error)}`)
280
+ }
281
+ })
282
+
135
283
  // HTTP API(前缀挂载,handler 内按子路径分发)。
284
+ // A1-08:自定义 webServer 路由不继承宿主鉴权门——每个请求先过
285
+ // connection.requestRejection,401/403 直接拒绝,避免 acks/busy 读写通道
286
+ // 成为绕过 bootstrap token/signed cookie 的安全空洞。
136
287
  ctx.webServer.register({
137
288
  path: API_PATH,
138
289
  handler(req, res) {
290
+ const rejection = ctx.connection?.requestRejection?.(req)
291
+ if (rejection !== undefined) {
292
+ res.writeHead(rejection, { 'Content-Type': 'text/plain' })
293
+ res.end('unauthorized')
294
+ return
295
+ }
139
296
  const url = new URL(req.url || '/', 'http://dsh-activity-pane')
140
297
  const route = url.pathname.slice(API_PATH.length)
141
298
  const method = req.method || 'GET'
@@ -188,6 +345,34 @@ export function apply(ctx) {
188
345
  })
189
346
  return
190
347
  }
348
+ if (route === '/busy' && method === 'GET') {
349
+ // 只读下发:响应立即返回当前全量快照;查询参数 ids(逗号分隔会话 id)指定
350
+ // 客户端当前可见的主会话,对其中无记录者触发懒回填(fire-and-forget),
351
+ // 回填完成经 /busy/stream 广播推送(R-01-020/AC-05)。
352
+ const idsParam = url.searchParams.get('ids') ?? ''
353
+ for (const id of idsParam.split(',')) {
354
+ if (id !== '') ensureTurnStatsFresh(id)
355
+ }
356
+ res.writeHead(200, { 'Content-Type': 'application/json' })
357
+ res.end(JSON.stringify(busySnapshot()))
358
+ return
359
+ }
360
+ if (route === '/busy/stream' && method === 'GET') {
361
+ // SSE:连接即发全量快照,此后每次变更广播;浏览器 EventSource 自动重连,
362
+ // 重连时再收一次全量,状态必然收敛。
363
+ res.writeHead(200, {
364
+ 'Content-Type': 'text/event-stream',
365
+ 'Cache-Control': 'no-cache',
366
+ Connection: 'keep-alive',
367
+ })
368
+ const hello = `event: state\ndata: ${JSON.stringify(busySnapshot())}\n\n`
369
+ res.write(hello)
370
+ busyStreamClients.add(res)
371
+ const remove = () => busyStreamClients.delete(res)
372
+ req.on('close', remove)
373
+ res.on('close', remove)
374
+ return
375
+ }
191
376
  res.writeHead(404, { 'Content-Type': 'application/json' })
192
377
  res.end(JSON.stringify({ ok: false, error: 'not found' }))
193
378
  },
@@ -203,5 +388,13 @@ export function apply(ctx) {
203
388
  }
204
389
  }
205
390
  streamClients.clear()
391
+ for (const res of busyStreamClients) {
392
+ try {
393
+ res.end()
394
+ } catch {
395
+ /* 连接已断 */
396
+ }
397
+ }
398
+ busyStreamClients.clear()
206
399
  }
207
400
  }
@@ -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 移动端回归)。