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/.dsh-plugin/client.js +978 -250
- package/package.json +1 -1
- package/scripts/acceptance.mjs +6 -2
- package/scripts/check.mjs +632 -164
- package/src/client.mjs +572 -150
- package/src/core.mjs +403 -98
- package/src/host.mjs +231 -4
- package/src/navigation.mjs +3 -2
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
|
}
|
package/src/navigation.mjs
CHANGED
|
@@ -84,8 +84,9 @@ export function scrollCardIntoView(scroll, card, behavior = "smooth") {
|
|
|
84
84
|
return Number(scroll.scrollTop) !== currentTop;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
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 移动端回归)。
|