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/.dsh-plugin/client.js +943 -203
- package/package.json +1 -1
- package/scripts/acceptance.mjs +4 -0
- package/scripts/check.mjs +545 -103
- package/src/client.mjs +565 -142
- package/src/core.mjs +375 -59
- package/src/host.mjs +197 -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,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
|
}
|
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 移动端回归)。
|