@chenkai114/dsh-team-admin 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,390 @@
1
+ // 只读半区:列根会话、读某会话的团队(成员快照 + 任务归属 + 实时状态)。
2
+ // 标题优先走宿主的 sessionQuery 折叠;不可用时按需读会话日志并做 TTL 缓存,
3
+ // 列表页只对真正需要的会话取标题,绝不为了标题全量解压所有会话。
4
+ import { stat } from 'node:fs/promises'
5
+
6
+ import { AdminError } from './error.mjs'
7
+
8
+ /** 默认列表条数与上限(与 src/api.mjs 保持一致)。 */
9
+ const DEFAULT_LIMIT = 50
10
+ const MAX_LIMIT = 200
11
+ /** 无 sessionQuery 时最多为多少个会话折叠标题(按最近活动取前 N)。 */
12
+ const TITLE_SCAN_LIMIT = 400
13
+ /** 标题内存缓存有效期。 */
14
+ const TITLE_TTL_MS = 30_000
15
+ /** 回退读日志时只取前 N 条事件(用于取首条 user/message)。 */
16
+ const TITLE_PREFIX_EVENTS = 80
17
+ /** 并发读日志折叠标题的上限。 */
18
+ const TITLE_CONCURRENCY = 4
19
+
20
+ /** 标题缓存按 ctx 归属,避免跨插件实例 / 跨测试串味。 */
21
+ const titleCaches = new WeakMap()
22
+
23
+ /** 取宿主服务(可选依赖用 ctx.get,避免未声明的 inject 抛错)。 */
24
+ function service(ctx, name) {
25
+ if (ctx === null || typeof ctx !== 'object') return undefined
26
+ if (typeof ctx.get === 'function') {
27
+ try {
28
+ const value = ctx.get(name)
29
+ if (value !== undefined && value !== null) return value
30
+ } catch {
31
+ // 服务未提供或严格模式下不可见:退回属性访问。
32
+ }
33
+ }
34
+ try {
35
+ return ctx[name]
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ /** 当前 ctx 的标题缓存。 */
42
+ function titleCache(ctx) {
43
+ let cache = titleCaches.get(ctx)
44
+ if (cache === undefined) {
45
+ cache = new Map()
46
+ titleCaches.set(ctx, cache)
47
+ }
48
+ return cache
49
+ }
50
+
51
+ /** 普通对象判定。 */
52
+ function isRecord(value) {
53
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
54
+ }
55
+
56
+ /** 文本块拼成一段纯文本。 */
57
+ function contentText(content) {
58
+ if (!Array.isArray(content)) return ''
59
+ const parts = []
60
+ for (const block of content) {
61
+ if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
62
+ }
63
+ return parts.join(' ').trim()
64
+ }
65
+
66
+ /** 折叠会话标题:最后一条 session/title,缺失回退首条 user/message 的前 40 字。 */
67
+ function foldTitleFromEvents(events) {
68
+ let title
69
+ let firstUserText
70
+ for (const event of events ?? []) {
71
+ if (!isRecord(event)) continue
72
+ if (event.type === 'session/title' && isRecord(event.data) && typeof event.data.title === 'string') {
73
+ const text = event.data.title.trim()
74
+ if (text !== '') title = text
75
+ continue
76
+ }
77
+ if (firstUserText === undefined && event.type === 'user/message' && isRecord(event.data)) {
78
+ const text = contentText(event.data.content)
79
+ if (text !== '') firstUserText = text.slice(0, 40)
80
+ }
81
+ }
82
+ return title ?? firstUserText
83
+ }
84
+
85
+ /**
86
+ * 读取一个会话的完整日志。
87
+ * @param {object} persistence - ctx.sessionPersistence。
88
+ * @param {string} sessionId - 会话 id。
89
+ * @returns {Promise<{header: object, events: object[]}>} 头部与全量事件。
90
+ */
91
+ export async function readSessionLog(persistence, sessionId) {
92
+ const handle = await persistence.open(sessionId, 'read')
93
+ try {
94
+ const { events } = await handle.read(0, undefined)
95
+ return { header: handle.header, events }
96
+ } finally {
97
+ await handle.close()
98
+ }
99
+ }
100
+
101
+ /** 会话文件 mtime(未知 → undefined);用于「最近活动」近似值。 */
102
+ async function artifactMtime(path) {
103
+ if (typeof path !== 'string' || path === '') return undefined
104
+ try {
105
+ const info = await stat(path)
106
+ return Number.isFinite(info.mtimeMs) ? info.mtimeMs : undefined
107
+ } catch {
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ /** 以有限并发跑一批异步任务,保留输入顺序的结果。 */
113
+ async function mapLimited(items, limit, fn) {
114
+ const results = new Array(items.length)
115
+ let cursor = 0
116
+ const workers = new Array(Math.min(limit, items.length)).fill(undefined).map(async () => {
117
+ for (;;) {
118
+ const index = cursor++
119
+ if (index >= items.length) return
120
+ results[index] = await fn(items[index], index)
121
+ }
122
+ })
123
+ await Promise.all(workers)
124
+ return results
125
+ }
126
+
127
+ /**
128
+ * 折叠一批会话的标题(缓存 + sessionQuery 批量优先 + 按需读日志兜底)。
129
+ * @param {object} ctx - 宿主上下文。
130
+ * @param {string[]} ids - 需要标题的会话 id。
131
+ * @param {object} persistence - ctx.sessionPersistence。
132
+ * @returns {Promise<Map<string, string>>} 已知标题(缺标题的会话映射为 id)。
133
+ */
134
+ async function collectTitles(ctx, ids, persistence) {
135
+ const cache = titleCache(ctx)
136
+ const now = Date.now()
137
+ const titles = new Map()
138
+ const pending = []
139
+ for (const id of ids) {
140
+ const cached = cache.get(id)
141
+ if (cached !== undefined && now - cached.at < TITLE_TTL_MS) titles.set(id, cached.title)
142
+ else pending.push(id)
143
+ }
144
+ if (pending.length === 0) return titles
145
+
146
+ const remember = (id, title) => {
147
+ cache.set(id, { title, at: Date.now() })
148
+ titles.set(id, title)
149
+ }
150
+
151
+ const query = service(ctx, 'sessionQuery')
152
+ const usable = query !== undefined && typeof query.readTitleSnapshots === 'function'
153
+ const unresolved = []
154
+ let batchWorked = false
155
+ if (usable) {
156
+ let results
157
+ try {
158
+ results = await query.readTitleSnapshots(pending)
159
+ } catch {
160
+ results = undefined
161
+ }
162
+ if (Array.isArray(results)) {
163
+ batchWorked = true
164
+ for (const [index, item] of results.entries()) {
165
+ const id = typeof item?.sessionId === 'string' ? item.sessionId : pending[index]
166
+ const title = item?.status === 'fulfilled' ? item.value?.title?.title : undefined
167
+ if (typeof title === 'string' && title.trim() !== '') remember(id, title.trim())
168
+ else unresolved.push(id)
169
+ }
170
+ } else {
171
+ unresolved.push(...pending)
172
+ }
173
+ } else {
174
+ unresolved.push(...pending)
175
+ }
176
+
177
+ // 批量折叠成功说明「没有标题」是权威结论,此时只读前缀找首条用户消息;
178
+ // 批量不可用(无 sessionQuery 或整批失败)才读全量日志折叠最后一条 session/title。
179
+ await mapLimited(unresolved, TITLE_CONCURRENCY, async (id) => {
180
+ try {
181
+ const handle = await persistence.open(id, 'read')
182
+ let events
183
+ try {
184
+ const slice = await handle.read(0, batchWorked ? TITLE_PREFIX_EVENTS : undefined)
185
+ events = slice.events
186
+ } finally {
187
+ await handle.close()
188
+ }
189
+ const title = foldTitleFromEvents(events)
190
+ if (typeof title === 'string' && title !== '') remember(id, title)
191
+ } catch {
192
+ // 会话不可读(已进回收站 / 被打断写坏):标题回退会话 id,不影响列表。
193
+ }
194
+ })
195
+ // 读不到标题的会话按会话 id 兜底,一并进缓存,避免每次列表都重读同一个坏会话。
196
+ for (const id of unresolved) if (!titles.has(id)) remember(id, id)
197
+ return titles
198
+ }
199
+
200
+ /** 一个会话的最近活动时间(未知回退创建时间)。 */
201
+ async function lastActivityOf(snapshot) {
202
+ const mtime = await artifactMtime(snapshot?.location?.path)
203
+ return mtime ?? (Number(snapshot?.header?.createdAt) || 0)
204
+ }
205
+
206
+ /** 实时状态:ctx.agents.get(id) 有值即已加载。 */
207
+ function liveAgent(ctx, id) {
208
+ const agents = service(ctx, 'agents')
209
+ if (agents === undefined || typeof agents.get !== 'function') return undefined
210
+ try {
211
+ return agents.get(id)
212
+ } catch {
213
+ return undefined
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 列根会话(parentSession 为空),按最近活动降序。
219
+ * @param {object} ctx - 宿主上下文。
220
+ * @param {{query?: string, limit?: number}} [options] - 搜索词与条数上限。
221
+ * @returns {Promise<object[]>} SessionRow 数组。
222
+ */
223
+ export async function listTeamSessions(ctx, { query = '', limit = DEFAULT_LIMIT } = {}) {
224
+ const persistence = service(ctx, 'sessionPersistence')
225
+ if (persistence === undefined || typeof persistence.list !== 'function') {
226
+ throw new AdminError('宿主会话服务不可用,无法列出会话', 'system', 500)
227
+ }
228
+ const effectiveLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_LIMIT) : DEFAULT_LIMIT
229
+ const snapshots = (await persistence.list()).filter((snapshot) => isRecord(snapshot?.header) && snapshot.header.parentSession === undefined)
230
+
231
+ // mtime 读盘有并发上限:几千个会话时不至于同时开几千个 fd。
232
+ const rows = await mapLimited(snapshots, 16, async (snapshot) => {
233
+ const id = String(snapshot.header.id)
234
+ const createdAt = Number(snapshot.header.createdAt) || 0
235
+ return {
236
+ id,
237
+ title: id,
238
+ cwd: typeof snapshot.header.cwd === 'string' ? snapshot.header.cwd : '',
239
+ createdAt,
240
+ lastActivityAt: await lastActivityOf(snapshot),
241
+ live: liveAgent(ctx, id) !== undefined,
242
+ }
243
+ })
244
+ rows.sort((left, right) => right.lastActivityAt - left.lastActivityAt || right.createdAt - left.createdAt)
245
+
246
+ const needle = typeof query === 'string' ? query.trim().toLowerCase() : ''
247
+ const titleIds = (needle === '' ? rows.slice(0, effectiveLimit) : rows.slice(0, TITLE_SCAN_LIMIT)).map((row) => row.id)
248
+ const titles = await collectTitles(ctx, titleIds, persistence)
249
+ for (const row of rows) row.title = titles.get(row.id) ?? row.id
250
+ if (needle === '') return rows.slice(0, effectiveLimit)
251
+ return rows
252
+ .filter((row) => row.title.toLowerCase().includes(needle)
253
+ || row.cwd.toLowerCase().includes(needle)
254
+ || row.id.toLowerCase().includes(needle))
255
+ .slice(0, effectiveLimit)
256
+ }
257
+
258
+ /**
259
+ * 纯函数:从 Lead 会话日志折叠团队 roster 与任务板。
260
+ * 同一成员 id 取最后一条快照;同一任务 id 取最大 revision;status='deleted' 的任务视为不存在。
261
+ * @param {readonly object[]} events - Lead 会话事件。
262
+ * @returns {{members: object[], tasks: object[], title?: string}} 成员(创建顺序)、未删除任务、最后一条标题。
263
+ */
264
+ export function parseRoster(events) {
265
+ const members = new Map()
266
+ const tasks = new Map()
267
+ let title
268
+ for (const event of events ?? []) {
269
+ if (!isRecord(event) || !isRecord(event.data)) continue
270
+ if (event.type === 'team/member' && isRecord(event.data.member)) {
271
+ const member = event.data.member
272
+ if (typeof member.id === 'string' && member.id !== '') members.set(member.id, { ...member })
273
+ continue
274
+ }
275
+ if (event.type === 'team/task' && isRecord(event.data.task)) {
276
+ const task = event.data.task
277
+ if (typeof task.id !== 'string' || task.id === '') continue
278
+ const revision = Number(task.revision)
279
+ const current = tasks.get(task.id)
280
+ if (current === undefined || (Number.isFinite(revision) && revision >= Number(current.revision))) {
281
+ tasks.set(task.id, { ...task })
282
+ }
283
+ continue
284
+ }
285
+ if (event.type === 'session/title' && typeof event.data.title === 'string' && event.data.title.trim() !== '') {
286
+ title = event.data.title.trim()
287
+ }
288
+ }
289
+ return {
290
+ members: [...members.values()],
291
+ tasks: [...tasks.values()].filter((task) => task.status !== 'deleted'),
292
+ ...title === undefined ? {} : { title },
293
+ }
294
+ }
295
+
296
+ /**
297
+ * 读一个根会话的团队详情。
298
+ * @param {object} ctx - 宿主上下文。
299
+ * @param {{sessionId: string, store: object}} request - 目标会话与墓碑存储。
300
+ * @returns {Promise<{session: object, members: object[], dismissed: object[]}>} 会话行、成员行、已删除记录。
301
+ * @throws {AdminError} 会话不存在(404)或不是根会话(400)。
302
+ */
303
+ export async function readTeam(ctx, { sessionId, store }) {
304
+ const persistence = service(ctx, 'sessionPersistence')
305
+ if (persistence === undefined || typeof persistence.stat !== 'function') {
306
+ throw new AdminError('宿主会话服务不可用,无法读取团队', 'system', 500)
307
+ }
308
+ if (typeof sessionId !== 'string' || sessionId === '') {
309
+ throw new AdminError('缺少 sessionId', 'bad-request', 400)
310
+ }
311
+ const snapshot = await statSnapshot(persistence, sessionId)
312
+ if (snapshot === undefined) throw new AdminError('会话不存在', 'not-found', 404)
313
+ if (snapshot.header.parentSession !== undefined) {
314
+ throw new AdminError('只能管理根会话的团队', 'bad-session', 400)
315
+ }
316
+
317
+ const log = await readSessionLog(persistence, sessionId)
318
+ const roster = parseRoster(log.events)
319
+ const title = roster.title ?? (await collectTitles(ctx, [sessionId], persistence)).get(sessionId) ?? sessionId
320
+ const session = {
321
+ id: sessionId,
322
+ title,
323
+ cwd: typeof snapshot.header.cwd === 'string' ? snapshot.header.cwd : '',
324
+ createdAt: Number(snapshot.header.createdAt) || 0,
325
+ lastActivityAt: await lastActivityOf(snapshot),
326
+ live: liveAgent(ctx, sessionId) !== undefined,
327
+ }
328
+
329
+ let childSnapshots = new Map()
330
+ if (typeof persistence.list === 'function') {
331
+ try {
332
+ childSnapshots = new Map((await persistence.list()).map((item) => [String(item.header.id), item]))
333
+ } catch {
334
+ childSnapshots = new Map()
335
+ }
336
+ }
337
+ const leadAgent = liveAgent(ctx, sessionId)
338
+ const dismissedRows = store === undefined || typeof store.listDismissed !== 'function'
339
+ ? []
340
+ : await store.listDismissed(sessionId)
341
+ const dismissedById = new Map(dismissedRows.map((row) => [row.id, row]))
342
+ // 校验过的 roster 只有 teammate;这里再挡一次 Lead 自身与同名 pseudo-row。
343
+ const teammates = roster.members.filter((member) => member.name !== 'lead' && String(member.id) !== sessionId)
344
+ const memberIds = new Set(teammates.map((member) => member.id))
345
+
346
+ const members = await Promise.all(teammates.map(async (member) => {
347
+ const id = String(member.id)
348
+ const live = liveAgent(ctx, id)
349
+ const child = childSnapshots.get(id)
350
+ const phase = member.phase === 'failed' || member.phase === 'provisioning' ? member.phase : 'active'
351
+ const model = live?.options?.model ?? leadAgent?.options?.model
352
+ const row = {
353
+ name: String(member.name ?? ''),
354
+ id,
355
+ phase,
356
+ status: phase === 'failed' ? 'failed' : phase === 'provisioning' ? 'provisioning' : live?.status === 'running' ? 'running' : 'inactive',
357
+ description: typeof member.description === 'string' ? member.description : '',
358
+ provider: typeof member.provider === 'string' ? member.provider : '',
359
+ context: member.context === 'fork' ? 'fork' : 'fresh',
360
+ ...model === undefined ? {} : { model },
361
+ ...typeof member.error === 'string' ? { error: member.error } : {},
362
+ dismissed: dismissedById.has(id),
363
+ sessionExists: live !== undefined || child !== undefined,
364
+ ownedTasks: roster.tasks.filter((task) => task.ownerId === id).length,
365
+ lastActivityAt: child === undefined ? null : await lastActivityOf(child) ?? null,
366
+ }
367
+ return row
368
+ }))
369
+
370
+ const dismissed = dismissedRows.map((row) => ({
371
+ name: row.name,
372
+ id: row.id,
373
+ dismissedAt: row.dismissedAt,
374
+ sessionDeleted: row.sessionDeleted === true,
375
+ ...typeof row.trashPath === 'string' ? { trashPath: row.trashPath } : {},
376
+ memberStillInRoster: memberIds.has(row.id),
377
+ }))
378
+
379
+ return { session, members, dismissed }
380
+ }
381
+
382
+ /** stat 包装:不存在返回 undefined,其他 I/O 错误照常抛出。 */
383
+ export async function statSnapshot(persistence, sessionId) {
384
+ try {
385
+ return await persistence.stat(sessionId)
386
+ } catch (error) {
387
+ if (error?.code === 'ENOENT' || error?.name === 'SessionPersistenceNotFoundError') return undefined
388
+ throw error
389
+ }
390
+ }