@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.
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +6 -0
- package/package.json +56 -4
- package/src/api.mjs +133 -0
- package/src/client.js +1006 -0
- package/src/dismiss.mjs +250 -0
- package/src/error.mjs +16 -0
- package/src/index.js +64 -0
- package/src/store.mjs +175 -0
- package/src/team-read.mjs +390 -0
|
@@ -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
|
+
}
|