@chenkai114/dsh-team-admin 0.1.1 → 0.1.3
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/README.md +8 -2
- package/package.json +1 -1
- package/src/client.js +11 -3
- package/src/dismiss.mjs +3 -1
- package/src/index.js +1 -1
- package/src/team-read.mjs +222 -96
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ v0.2.x 的内置 Team roster 是 **append-only**:只能 `spawn_teammate` 加
|
|
|
18
18
|
dsh plugin --profile web add @chenkai114/dsh-team-admin
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
重载后左侧栏会出现 **团队管理** 入口(插件市场里本包的配置卡也保留)。
|
|
22
22
|
|
|
23
23
|
未发布前用本地源码链接安装:
|
|
24
24
|
|
|
@@ -34,7 +34,13 @@ dsh plugin --profile web add link:/Users/chenkai2/data1/www/htdocs/dsh-team-admi
|
|
|
34
34
|
}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
左侧栏「团队管理」入口(0.1.1 起):
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
打开后是完整管理面板,可直接对真实会话操作:
|
|
42
|
+
|
|
43
|
+

|
|
38
44
|
|
|
39
45
|
## 用法
|
|
40
46
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -104,9 +104,17 @@
|
|
|
104
104
|
.ta-confirm__actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
105
105
|
.ta-summary{font-size:12px;color:var(--ta-text-sub);line-height:1.5}
|
|
106
106
|
.ta-host{display:block;min-width:0}
|
|
107
|
-
|
|
108
|
-
.ta-
|
|
109
|
-
|
|
107
|
+
/* 整页面板:宿主 main 槽是 flex column,这里把卡片与左右两栏一路撑满,滚动只发生在内部列表 */
|
|
108
|
+
.ta-main{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;padding:18px;background:var(--dsw-alias-bg-base)}
|
|
109
|
+
.ta-main__inner{display:flex;flex-direction:column;flex:1;min-height:0;width:100%;min-width:0}
|
|
110
|
+
.ta-main .ta-host{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
111
|
+
.ta-main .ta-root{flex:1;min-height:0}
|
|
112
|
+
.ta-main .ta-body{flex:1;min-height:0;grid-template-rows:minmax(0,1fr);align-items:stretch}
|
|
113
|
+
.ta-main .ta-col{min-height:0}
|
|
114
|
+
.ta-main .ta-list-host{display:flex;flex-direction:column;flex:1;min-height:0}
|
|
115
|
+
.ta-main .ta-sessions{flex:1;min-height:0;max-height:none}
|
|
116
|
+
.ta-main .ta-col--detail{overflow:auto;padding-right:2px}
|
|
117
|
+
@media (max-width:760px){.ta-body{grid-template-columns:1fr}.ta-col--list{padding-right:0;padding-bottom:10px;border-right:0;border-bottom:1px solid var(--ta-border)}.ta-sessions{max-height:220px}.ta-main .ta-body{grid-template-rows:minmax(0,1fr) minmax(0,1fr)}}
|
|
110
118
|
@media (prefers-reduced-motion:reduce){.ta-btn{transition:none}}
|
|
111
119
|
`
|
|
112
120
|
|
package/src/dismiss.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { cp, lstat, mkdir, rename, rm } from 'node:fs/promises'
|
|
|
5
5
|
import { basename, dirname, join } from 'node:path'
|
|
6
6
|
|
|
7
7
|
import { AdminError } from './error.mjs'
|
|
8
|
-
import { parseRoster, readSessionLog, statSnapshot } from './team-read.mjs'
|
|
8
|
+
import { invalidateSessionListCache, parseRoster, readSessionLog, statSnapshot } from './team-read.mjs'
|
|
9
9
|
|
|
10
10
|
/** 停止成员的总等待上限(契约固定 15s)。 */
|
|
11
11
|
const DEFAULT_INTERRUPT_TIMEOUT_MS = 15_000
|
|
@@ -225,6 +225,8 @@ export async function dismissMember(ctx, request, options = {}) {
|
|
|
225
225
|
dirName: memberId + '-' + stamp,
|
|
226
226
|
})
|
|
227
227
|
sessionDeleted = true
|
|
228
|
+
// 会话文件已消失:让下一轮列表 / 团队详情立刻重扫,而不是等 60s 缓存过期。
|
|
229
|
+
invalidateSessionListCache(ctx)
|
|
228
230
|
} catch (error) {
|
|
229
231
|
logSystem('[team-admin] 移动成员 ' + memberId + ' 的会话失败:' + String(error?.stack ?? error))
|
|
230
232
|
throw new AdminError('成员会话移动失败,请检查服务端日志', 'system', 500)
|
package/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ const DATA_DIR_ENV = 'DSH_TEAM_ADMIN_DATA_DIR'
|
|
|
17
17
|
/** 路由前缀:webServer 只支持 exact/prefix 注册。 */
|
|
18
18
|
const ROUTE_PREFIX = '/api/team-admin'
|
|
19
19
|
/** 与 package.json 版本保持一致(发布前由测试校验)。 */
|
|
20
|
-
const PLUGIN_VERSION = '0.1.
|
|
20
|
+
const PLUGIN_VERSION = '0.1.3'
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* 解析插件数据目录。
|
package/src/team-read.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// 只读半区:列根会话、读某会话的团队(成员快照 + 任务归属 + 实时状态)。
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
//
|
|
3
|
+
// 性能约定(列表页冷启动 / 暖调用):
|
|
4
|
+
// - 会话快照列表 + mtime + live 按 ctx 缓存 60s,同一 ctx 的并发调用共享一次扫描(inFlight 去重);
|
|
5
|
+
// query 与 limit 都在缓存结果上做,绝不每次调用都重新 list() 一遍整个会话库。
|
|
6
|
+
// - 标题优先取宿主的 projection cache(sessionProjectionCache,零 I/O 的已折叠行),
|
|
7
|
+
// 其次 sessionQuery 批量折叠,最后按上限读日志前缀;不会为了标题全量解压会话。
|
|
4
8
|
import { stat } from 'node:fs/promises'
|
|
5
9
|
|
|
6
10
|
import { AdminError } from './error.mjs'
|
|
@@ -8,16 +12,25 @@ import { AdminError } from './error.mjs'
|
|
|
8
12
|
/** 默认列表条数与上限(与 src/api.mjs 保持一致)。 */
|
|
9
13
|
const DEFAULT_LIMIT = 50
|
|
10
14
|
const MAX_LIMIT = 200
|
|
11
|
-
/**
|
|
12
|
-
const
|
|
13
|
-
/**
|
|
14
|
-
const
|
|
15
|
-
/**
|
|
16
|
-
const
|
|
17
|
-
/**
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
/** 会话快照扫描缓存有效期(列表允许 1 分钟陈旧)。 */
|
|
16
|
+
const SCAN_TTL_MS = 60_000
|
|
17
|
+
/** 扫描时读 mtime 的并发上限,避免几千个会话同时开 fd。 */
|
|
18
|
+
const SCAN_CONCURRENCY = 16
|
|
19
|
+
/** 冷扫描超过该耗时打一条 warn,便于从服务端日志定位宿主存储变慢。 */
|
|
20
|
+
const COLD_WARN_MS = 2_000
|
|
21
|
+
/** 标题缓存有效期(与扫描缓存对齐)。 */
|
|
22
|
+
const TITLE_TTL_MS = 60_000
|
|
23
|
+
/** 一次 sessionQuery 批量折叠标题的上限(它读日志实现,冷启动时不能放大)。 */
|
|
24
|
+
const TITLE_QUERY_LIMIT = 32
|
|
25
|
+
/** 一次为标题读日志前缀的会话数上限。 */
|
|
26
|
+
const PREFIX_READ_LIMIT = 16
|
|
27
|
+
/** 读日志前缀时最多取的事件数。 */
|
|
28
|
+
const PREFIX_EVENTS = 80
|
|
29
|
+
/** 前缀读取并发上限。 */
|
|
30
|
+
const PREFIX_CONCURRENCY = 2
|
|
31
|
+
|
|
32
|
+
/** 会话扫描缓存与标题缓存都按 ctx 归属,避免跨插件实例 / 跨测试串味。 */
|
|
33
|
+
const scanStates = new WeakMap()
|
|
21
34
|
const titleCaches = new WeakMap()
|
|
22
35
|
|
|
23
36
|
/** 取宿主服务(可选依赖用 ctx.get,避免未声明的 inject 抛错)。 */
|
|
@@ -38,14 +51,13 @@ function service(ctx, name) {
|
|
|
38
51
|
}
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
/**
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
/** 打一条宿主 warn(日志不可用时静默)。 */
|
|
55
|
+
function logWarn(ctx, message) {
|
|
56
|
+
try {
|
|
57
|
+
if (typeof ctx?.logger?.warn === 'function') ctx.logger.warn(message)
|
|
58
|
+
} catch {
|
|
59
|
+
// 日志输出失败不影响接口结果。
|
|
47
60
|
}
|
|
48
|
-
return cache
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
/** 普通对象判定。 */
|
|
@@ -98,7 +110,7 @@ export async function readSessionLog(persistence, sessionId) {
|
|
|
98
110
|
}
|
|
99
111
|
}
|
|
100
112
|
|
|
101
|
-
/**
|
|
113
|
+
/** 会话产物文件 mtime(未知 → undefined);用于「最近活动」近似值。 */
|
|
102
114
|
async function artifactMtime(path) {
|
|
103
115
|
if (typeof path !== 'string' || path === '') return undefined
|
|
104
116
|
try {
|
|
@@ -124,14 +136,132 @@ async function mapLimited(items, limit, fn) {
|
|
|
124
136
|
return results
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
/** 某个 ctx 的会话扫描缓存槽。 */
|
|
140
|
+
function scanState(ctx) {
|
|
141
|
+
let state = scanStates.get(ctx)
|
|
142
|
+
if (state === undefined) {
|
|
143
|
+
state = { at: 0, rows: undefined, headers: undefined, inFlight: undefined }
|
|
144
|
+
scanStates.set(ctx, state)
|
|
145
|
+
}
|
|
146
|
+
return state
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 当前 ctx 的标题缓存。 */
|
|
150
|
+
function titleCache(ctx) {
|
|
151
|
+
let cache = titleCaches.get(ctx)
|
|
152
|
+
if (cache === undefined) {
|
|
153
|
+
cache = new Map()
|
|
154
|
+
titleCaches.set(ctx, cache)
|
|
155
|
+
}
|
|
156
|
+
return cache
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
/**
|
|
128
|
-
*
|
|
160
|
+
* 丢弃某个 ctx 的会话扫描缓存(删除成员后可让列表 / 团队详情立刻反映文件变化)。
|
|
161
|
+
* @param {object} ctx - 宿主上下文。
|
|
162
|
+
*/
|
|
163
|
+
export function invalidateSessionListCache(ctx) {
|
|
164
|
+
scanStates.delete(ctx)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 一个会话的最近活动时间(未知回退创建时间)。 */
|
|
168
|
+
async function lastActivityOf(snapshot) {
|
|
169
|
+
const mtime = await artifactMtime(snapshot?.location?.path)
|
|
170
|
+
return mtime ?? (Number(snapshot?.header?.createdAt) || 0)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 实时状态:ctx.agents.get(id) 有值即已加载。 */
|
|
174
|
+
function liveAgent(ctx, id) {
|
|
175
|
+
const agents = service(ctx, 'agents')
|
|
176
|
+
if (agents === undefined || typeof agents.get !== 'function') return undefined
|
|
177
|
+
try {
|
|
178
|
+
return agents.get(id)
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 扫一次会话库(含全部会话,不只是根会话),结果按 ctx 缓存。
|
|
186
|
+
* 同一 ctx 的并发冷调用共享同一次扫描;扫描失败且存在旧缓存时先给旧数据。
|
|
129
187
|
* @param {object} ctx - 宿主上下文。
|
|
130
|
-
* @param {string[]} ids - 需要标题的会话 id。
|
|
131
188
|
* @param {object} persistence - ctx.sessionPersistence。
|
|
132
|
-
* @
|
|
189
|
+
* @param {{maxAgeMs?: number}} [options] - 缓存有效期覆盖(默认 60s,0 表示强制重扫)。
|
|
190
|
+
* @returns {Promise<{at: number, rows: object[], headers: Map<string, object>}>} 缓存槽(含全部会话行)。
|
|
191
|
+
* @throws {AdminError} 首次扫描失败(无旧缓存可退)。
|
|
133
192
|
*/
|
|
134
|
-
async function
|
|
193
|
+
async function scanSessions(ctx, persistence, { maxAgeMs = SCAN_TTL_MS } = {}) {
|
|
194
|
+
const state = scanState(ctx)
|
|
195
|
+
if (state.rows !== undefined && Date.now() - state.at < maxAgeMs) return state
|
|
196
|
+
if (state.inFlight !== undefined) return state.inFlight
|
|
197
|
+
|
|
198
|
+
const started = Date.now()
|
|
199
|
+
state.inFlight = (async () => {
|
|
200
|
+
const snapshots = await persistence.list()
|
|
201
|
+
const rows = []
|
|
202
|
+
const headers = new Map()
|
|
203
|
+
await mapLimited(snapshots, SCAN_CONCURRENCY, async (snapshot) => {
|
|
204
|
+
if (!isRecord(snapshot?.header)) return
|
|
205
|
+
const id = String(snapshot.header.id)
|
|
206
|
+
headers.set(id, snapshot.header)
|
|
207
|
+
rows.push({
|
|
208
|
+
id,
|
|
209
|
+
cwd: typeof snapshot.header.cwd === 'string' ? snapshot.header.cwd : '',
|
|
210
|
+
createdAt: Number(snapshot.header.createdAt) || 0,
|
|
211
|
+
lastActivityAt: await lastActivityOf(snapshot),
|
|
212
|
+
live: liveAgent(ctx, id) !== undefined,
|
|
213
|
+
parentSession: snapshot.header.parentSession,
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
state.rows = rows
|
|
217
|
+
state.headers = headers
|
|
218
|
+
state.at = Date.now()
|
|
219
|
+
const took = state.at - started
|
|
220
|
+
if (took >= COLD_WARN_MS) {
|
|
221
|
+
logWarn(ctx, '[team-admin] 会话列表冷扫描耗时 ' + took + 'ms(' + rows.length + ' 个会话,缓存 ' + Math.round(SCAN_TTL_MS / 1000) + 's)')
|
|
222
|
+
}
|
|
223
|
+
return state
|
|
224
|
+
})()
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
return await state.inFlight
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (state.rows !== undefined) {
|
|
230
|
+
logWarn(ctx, '[team-admin] 会话扫描失败,继续使用 ' + Math.round((Date.now() - state.at) / 1000) + 's 前的缓存:' + String(error?.message ?? error))
|
|
231
|
+
return state
|
|
232
|
+
}
|
|
233
|
+
throw error
|
|
234
|
+
} finally {
|
|
235
|
+
state.inFlight = undefined
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 从宿主 projection cache 取已折叠标题(同步零 I/O,与宿主冷列表一致)。
|
|
241
|
+
* @param {object} projections - ctx.sessionProjectionCache。
|
|
242
|
+
* @param {object} header - 会话头部(生命周期身份校验用)。
|
|
243
|
+
* @returns {string|undefined} 标题;无可用行时 undefined。
|
|
244
|
+
*/
|
|
245
|
+
function projectionTitle(projections, header) {
|
|
246
|
+
if (projections === undefined || header === undefined || typeof projections.cachedSnapshot !== 'function') return undefined
|
|
247
|
+
try {
|
|
248
|
+
const block = projections.cachedSnapshot(header, ['title'])
|
|
249
|
+
const value = block?.values?.title
|
|
250
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
251
|
+
} catch {
|
|
252
|
+
return undefined
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 折叠一批会话的标题(缓存 → projection cache → sessionQuery 批量 → 日志前缀 → 会话 id)。
|
|
258
|
+
* @param {object} ctx - 宿主上下文。
|
|
259
|
+
* @param {string[]} ids - 需要标题的会话 id。
|
|
260
|
+
* @param {object} persistence - ctx.sessionPersistence(前缀读取兜底用)。
|
|
261
|
+
* @param {Map<string, object>} [headers] - id → 会话头部(projection cache 身份校验用)。
|
|
262
|
+
* @returns {Promise<Map<string, string>>} 标题表(读不到的会话映射为 id)。
|
|
263
|
+
*/
|
|
264
|
+
async function collectTitles(ctx, ids, persistence, headers = new Map()) {
|
|
135
265
|
const cache = titleCache(ctx)
|
|
136
266
|
const now = Date.now()
|
|
137
267
|
const titles = new Map()
|
|
@@ -148,40 +278,46 @@ async function collectTitles(ctx, ids, persistence) {
|
|
|
148
278
|
titles.set(id, title)
|
|
149
279
|
}
|
|
150
280
|
|
|
151
|
-
|
|
152
|
-
const
|
|
281
|
+
// 1) 宿主 projection cache:同步命中,冷列表的主要标题来源。
|
|
282
|
+
const projections = service(ctx, 'sessionProjectionCache')
|
|
153
283
|
const unresolved = []
|
|
154
|
-
|
|
155
|
-
|
|
284
|
+
for (const id of pending) {
|
|
285
|
+
const title = projectionTitle(projections, headers.get(id))
|
|
286
|
+
if (title === undefined) unresolved.push(id)
|
|
287
|
+
else remember(id, title)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 2) sessionQuery 批量折叠:它按日志实现,只在剩余量可控时才调用,避免冷启动放大。
|
|
291
|
+
let remaining = unresolved
|
|
292
|
+
const query = service(ctx, 'sessionQuery')
|
|
293
|
+
if (remaining.length > 0 && remaining.length <= TITLE_QUERY_LIMIT
|
|
294
|
+
&& query !== undefined && typeof query.readTitleSnapshots === 'function') {
|
|
156
295
|
let results
|
|
157
296
|
try {
|
|
158
|
-
results = await query.readTitleSnapshots(
|
|
297
|
+
results = await query.readTitleSnapshots(remaining)
|
|
159
298
|
} catch {
|
|
160
299
|
results = undefined
|
|
161
300
|
}
|
|
162
301
|
if (Array.isArray(results)) {
|
|
163
|
-
|
|
302
|
+
const stillOpen = []
|
|
164
303
|
for (const [index, item] of results.entries()) {
|
|
165
|
-
const id = typeof item?.sessionId === 'string' ? item.sessionId :
|
|
304
|
+
const id = typeof item?.sessionId === 'string' ? item.sessionId : remaining[index]
|
|
166
305
|
const title = item?.status === 'fulfilled' ? item.value?.title?.title : undefined
|
|
167
306
|
if (typeof title === 'string' && title.trim() !== '') remember(id, title.trim())
|
|
168
|
-
else
|
|
307
|
+
else stillOpen.push(id)
|
|
169
308
|
}
|
|
170
|
-
|
|
171
|
-
unresolved.push(...pending)
|
|
309
|
+
remaining = stillOpen
|
|
172
310
|
}
|
|
173
|
-
} else {
|
|
174
|
-
unresolved.push(...pending)
|
|
175
311
|
}
|
|
176
312
|
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
await mapLimited(
|
|
313
|
+
// 3) 有上限的日志前缀读取:取 session/title 或首条 user/message,绝不整篇解压。
|
|
314
|
+
const prefixIds = remaining.slice(0, PREFIX_READ_LIMIT)
|
|
315
|
+
await mapLimited(prefixIds, PREFIX_CONCURRENCY, async (id) => {
|
|
180
316
|
try {
|
|
181
317
|
const handle = await persistence.open(id, 'read')
|
|
182
318
|
let events
|
|
183
319
|
try {
|
|
184
|
-
const slice = await handle.read(0,
|
|
320
|
+
const slice = await handle.read(0, PREFIX_EVENTS)
|
|
185
321
|
events = slice.events
|
|
186
322
|
} finally {
|
|
187
323
|
await handle.close()
|
|
@@ -192,61 +328,53 @@ async function collectTitles(ctx, ids, persistence) {
|
|
|
192
328
|
// 会话不可读(已进回收站 / 被打断写坏):标题回退会话 id,不影响列表。
|
|
193
329
|
}
|
|
194
330
|
})
|
|
195
|
-
|
|
196
|
-
|
|
331
|
+
|
|
332
|
+
// 4) 兜底:读不到标题的会话按会话 id 记住,避免每次列表都重读同一个坏会话。
|
|
333
|
+
for (const id of pending) if (!titles.has(id)) remember(id, id)
|
|
197
334
|
return titles
|
|
198
335
|
}
|
|
199
336
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
337
|
+
/** 把内部扫描行换成对外的 SessionRow(只输出契约字段)。 */
|
|
338
|
+
function toSessionRow(row, title) {
|
|
339
|
+
return {
|
|
340
|
+
id: row.id,
|
|
341
|
+
title,
|
|
342
|
+
cwd: row.cwd,
|
|
343
|
+
createdAt: row.createdAt,
|
|
344
|
+
lastActivityAt: row.lastActivityAt,
|
|
345
|
+
live: row.live,
|
|
346
|
+
}
|
|
204
347
|
}
|
|
205
348
|
|
|
206
|
-
/**
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return agents.get(id)
|
|
212
|
-
} catch {
|
|
213
|
-
return undefined
|
|
214
|
-
}
|
|
349
|
+
/** 列表排序:最近活动降序,其次创建时间降序,最后按 id 保证稳定。 */
|
|
350
|
+
function compareActivity(left, right) {
|
|
351
|
+
return right.lastActivityAt - left.lastActivityAt
|
|
352
|
+
|| right.createdAt - left.createdAt
|
|
353
|
+
|| left.id.localeCompare(right.id)
|
|
215
354
|
}
|
|
216
355
|
|
|
217
356
|
/**
|
|
218
357
|
* 列根会话(parentSession 为空),按最近活动降序。
|
|
219
358
|
* @param {object} ctx - 宿主上下文。
|
|
220
|
-
* @param {{query?: string, limit?: number}} [options] -
|
|
359
|
+
* @param {{query?: string, limit?: number, maxAgeMs?: number}} [options] - 搜索词、条数上限、扫描缓存有效期覆盖。
|
|
221
360
|
* @returns {Promise<object[]>} SessionRow 数组。
|
|
222
361
|
*/
|
|
223
|
-
export async function listTeamSessions(ctx, { query = '', limit = DEFAULT_LIMIT } = {}) {
|
|
362
|
+
export async function listTeamSessions(ctx, { query = '', limit = DEFAULT_LIMIT, maxAgeMs = SCAN_TTL_MS } = {}) {
|
|
224
363
|
const persistence = service(ctx, 'sessionPersistence')
|
|
225
364
|
if (persistence === undefined || typeof persistence.list !== 'function') {
|
|
226
365
|
throw new AdminError('宿主会话服务不可用,无法列出会话', 'system', 500)
|
|
227
366
|
}
|
|
228
367
|
const effectiveLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_LIMIT) : DEFAULT_LIMIT
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
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)
|
|
368
|
+
const scan = await scanSessions(ctx, persistence, { maxAgeMs })
|
|
369
|
+
const roots = scan.rows.filter((row) => row.parentSession === undefined)
|
|
370
|
+
roots.sort(compareActivity)
|
|
245
371
|
|
|
246
372
|
const needle = typeof query === 'string' ? query.trim().toLowerCase() : ''
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
373
|
+
// 搜索要对标题做子串匹配,因此搜索时给全部根会话取标题(标题来源都是缓存/零 I/O,
|
|
374
|
+
// 只有极少数会话才会触发前缀读取);无搜索时只取将返回的那一段。
|
|
375
|
+
const titleIds = (needle === '' ? roots.slice(0, effectiveLimit) : roots).map((row) => row.id)
|
|
376
|
+
const titles = await collectTitles(ctx, titleIds, persistence, scan.headers)
|
|
377
|
+
const rows = roots.map((row) => toSessionRow(row, titles.get(row.id) ?? row.id))
|
|
250
378
|
if (needle === '') return rows.slice(0, effectiveLimit)
|
|
251
379
|
return rows
|
|
252
380
|
.filter((row) => row.title.toLowerCase().includes(needle)
|
|
@@ -266,7 +394,13 @@ export function parseRoster(events) {
|
|
|
266
394
|
const tasks = new Map()
|
|
267
395
|
let title
|
|
268
396
|
for (const event of events ?? []) {
|
|
269
|
-
if (!isRecord(event)
|
|
397
|
+
if (!isRecord(event)) continue
|
|
398
|
+
if (event.type === 'session/title' && isRecord(event.data) && typeof event.data.title === 'string') {
|
|
399
|
+
const text = event.data.title.trim()
|
|
400
|
+
if (text !== '') title = text
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
if (!isRecord(event.data)) continue
|
|
270
404
|
if (event.type === 'team/member' && isRecord(event.data.member)) {
|
|
271
405
|
const member = event.data.member
|
|
272
406
|
if (typeof member.id === 'string' && member.id !== '') members.set(member.id, { ...member })
|
|
@@ -280,10 +414,6 @@ export function parseRoster(events) {
|
|
|
280
414
|
if (current === undefined || (Number.isFinite(revision) && revision >= Number(current.revision))) {
|
|
281
415
|
tasks.set(task.id, { ...task })
|
|
282
416
|
}
|
|
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
417
|
}
|
|
288
418
|
}
|
|
289
419
|
return {
|
|
@@ -316,7 +446,11 @@ export async function readTeam(ctx, { sessionId, store }) {
|
|
|
316
446
|
|
|
317
447
|
const log = await readSessionLog(persistence, sessionId)
|
|
318
448
|
const roster = parseRoster(log.events)
|
|
319
|
-
const
|
|
449
|
+
const scan = await scanSessions(ctx, persistence)
|
|
450
|
+
const headers = new Map([[sessionId, snapshot.header]])
|
|
451
|
+
const title = roster.title
|
|
452
|
+
?? (await collectTitles(ctx, [sessionId], persistence, headers)).get(sessionId)
|
|
453
|
+
?? sessionId
|
|
320
454
|
const session = {
|
|
321
455
|
id: sessionId,
|
|
322
456
|
title,
|
|
@@ -326,14 +460,7 @@ export async function readTeam(ctx, { sessionId, store }) {
|
|
|
326
460
|
live: liveAgent(ctx, sessionId) !== undefined,
|
|
327
461
|
}
|
|
328
462
|
|
|
329
|
-
|
|
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
|
-
}
|
|
463
|
+
const childRows = new Map(scan.rows.map((row) => [row.id, row]))
|
|
337
464
|
const leadAgent = liveAgent(ctx, sessionId)
|
|
338
465
|
const dismissedRows = store === undefined || typeof store.listDismissed !== 'function'
|
|
339
466
|
? []
|
|
@@ -343,13 +470,13 @@ export async function readTeam(ctx, { sessionId, store }) {
|
|
|
343
470
|
const teammates = roster.members.filter((member) => member.name !== 'lead' && String(member.id) !== sessionId)
|
|
344
471
|
const memberIds = new Set(teammates.map((member) => member.id))
|
|
345
472
|
|
|
346
|
-
const members =
|
|
473
|
+
const members = teammates.map((member) => {
|
|
347
474
|
const id = String(member.id)
|
|
348
475
|
const live = liveAgent(ctx, id)
|
|
349
|
-
const child =
|
|
476
|
+
const child = childRows.get(id)
|
|
350
477
|
const phase = member.phase === 'failed' || member.phase === 'provisioning' ? member.phase : 'active'
|
|
351
478
|
const model = live?.options?.model ?? leadAgent?.options?.model
|
|
352
|
-
|
|
479
|
+
return {
|
|
353
480
|
name: String(member.name ?? ''),
|
|
354
481
|
id,
|
|
355
482
|
phase,
|
|
@@ -362,10 +489,9 @@ export async function readTeam(ctx, { sessionId, store }) {
|
|
|
362
489
|
dismissed: dismissedById.has(id),
|
|
363
490
|
sessionExists: live !== undefined || child !== undefined,
|
|
364
491
|
ownedTasks: roster.tasks.filter((task) => task.ownerId === id).length,
|
|
365
|
-
lastActivityAt: child === undefined ? null :
|
|
492
|
+
lastActivityAt: child === undefined ? null : child.lastActivityAt,
|
|
366
493
|
}
|
|
367
|
-
|
|
368
|
-
}))
|
|
494
|
+
})
|
|
369
495
|
|
|
370
496
|
const dismissed = dismissedRows.map((row) => ({
|
|
371
497
|
name: row.name,
|