@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,250 @@
1
+ // 删除角色:先停会话(中断 + 轮询等它退出),再把成员会话目录移进
2
+ // <dataDir>/trash/<leadSessionId>/<memberId>-<毫秒>/,最后写墓碑。
3
+ // 移动而不是删除:可恢复,且 session-query 索引会按「文件消失」自动摘除。
4
+ import { cp, lstat, mkdir, rename, rm } from 'node:fs/promises'
5
+ import { basename, dirname, join } from 'node:path'
6
+
7
+ import { AdminError } from './error.mjs'
8
+ import { parseRoster, readSessionLog, statSnapshot } from './team-read.mjs'
9
+
10
+ /** 停止成员的总等待上限(契约固定 15s)。 */
11
+ const DEFAULT_INTERRUPT_TIMEOUT_MS = 15_000
12
+ /** 轮询成员状态的间隔。 */
13
+ const STOP_POLL_MS = 100
14
+ /** 回收站目录名。 */
15
+ const TRASH_DIR = 'trash'
16
+
17
+ /** 取宿主服务(可选依赖一律走 ctx.get,避免未声明的 inject 抛错)。 */
18
+ function service(ctx, name) {
19
+ if (ctx === null || typeof ctx !== 'object') return undefined
20
+ if (typeof ctx.get === 'function') {
21
+ try {
22
+ const value = ctx.get(name)
23
+ if (value !== undefined && value !== null) return value
24
+ } catch {
25
+ // 服务未提供:退回属性访问。
26
+ }
27
+ }
28
+ try {
29
+ return ctx[name]
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }
34
+
35
+ /** 普通对象判定。 */
36
+ function isRecord(value) {
37
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
38
+ }
39
+
40
+ /** 睡一会(仅用于停止轮询)。 */
41
+ function delay(ms) {
42
+ return new Promise((resolve) => setTimeout(resolve, ms))
43
+ }
44
+
45
+ /**
46
+ * 还原持久化后端对路径片的编码(非安全字符编码成 ~XXXX),用于判断某个目录是否就是该会话的目录。
47
+ * @param {string} segment - 路径片。
48
+ * @returns {string} 还原后的文本。
49
+ */
50
+ function decodeSegment(segment) {
51
+ return segment.replace(/~([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
52
+ }
53
+
54
+ /**
55
+ * 定位要移动的源路径:优先整个会话目录(同一会话的多个 format generation 一起走),
56
+ * 否则只移动该会话的日志文件。
57
+ * @param {string} locationPath - 持久化后端给出的会话产物路径。
58
+ * @param {string} memberId - 成员会话 id。
59
+ * @returns {Promise<{path: string, isDirectory: boolean}|undefined>} 源路径;产物已不存在时为 undefined。
60
+ */
61
+ async function resolveSource(locationPath, memberId) {
62
+ const info = await lstat(locationPath).catch(() => undefined)
63
+ if (info === undefined) return undefined
64
+ if (info.isDirectory()) return { path: locationPath, isDirectory: true }
65
+ const parent = dirname(locationPath)
66
+ if (decodeSegment(basename(parent)) === memberId) {
67
+ const parentInfo = await lstat(parent).catch(() => undefined)
68
+ if (parentInfo?.isDirectory() === true) return { path: parent, isDirectory: true }
69
+ }
70
+ return { path: locationPath, isDirectory: false }
71
+ }
72
+
73
+ /**
74
+ * 把一个会话产物移动到回收站目录(rename 优先;跨设备 EXDEV 时复制后删除原路径)。
75
+ * @param {{sourcePath: string, isDirectory?: boolean, trashRoot: string, dirName: string, renameFn?: typeof rename}} options
76
+ * sourcePath 为源路径;trashRoot 为回收站根;dirName 为本次回收目录名;renameFn 仅供单测注入。
77
+ * @returns {Promise<string>} 回收目录绝对路径。
78
+ */
79
+ export async function moveSessionArtifact({ sourcePath, isDirectory = false, trashRoot, dirName, renameFn = rename }) {
80
+ await mkdir(trashRoot, { recursive: true })
81
+ const target = join(trashRoot, dirName)
82
+ if (isDirectory) {
83
+ try {
84
+ await renameFn(sourcePath, target)
85
+ return target
86
+ } catch (error) {
87
+ if (error?.code !== 'EXDEV') throw error
88
+ }
89
+ await cp(sourcePath, target, { recursive: true, force: false, errorOnExist: false })
90
+ // EXDEV 兜底:复制成功后删除原目录(唯一允许删除原目录的分支,绝不用它做常规删除)。
91
+ await rm(sourcePath, { recursive: true, force: true })
92
+ return target
93
+ }
94
+ const finalPath = join(target, basename(sourcePath))
95
+ try {
96
+ await mkdir(target, { recursive: true })
97
+ await renameFn(sourcePath, finalPath)
98
+ return target
99
+ } catch (error) {
100
+ if (error?.code !== 'EXDEV') throw error
101
+ }
102
+ await mkdir(target, { recursive: true })
103
+ await cp(sourcePath, finalPath, { force: false, errorOnExist: false })
104
+ await rm(sourcePath, { force: true })
105
+ return target
106
+ }
107
+
108
+ /** 读 live Agent(不可用时 undefined)。 */
109
+ function liveAgent(ctx, id) {
110
+ const agents = service(ctx, 'agents')
111
+ if (agents === undefined || typeof agents.get !== 'function') return undefined
112
+ try {
113
+ return agents.get(id)
114
+ } catch {
115
+ return undefined
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 停掉仍在运行的成员:中断 + 轮询到非 running。
121
+ * @param {object} ctx - 宿主上下文。
122
+ * @param {{leadSessionId: string, memberId: string, timeoutMs: number, logSystem: (line: string) => void}} request - 根会话、成员、等待上限、日志函数。
123
+ * @returns {Promise<boolean>} 是否中断并等到退出(本来就没在运行 → false)。
124
+ * @throws {AdminError} 超过等待上限仍 running(409)。
125
+ */
126
+ async function stopMember(ctx, { leadSessionId, memberId, timeoutMs, logSystem }) {
127
+ if (liveAgent(ctx, memberId)?.status !== 'running') return false
128
+ const leadAgent = liveAgent(ctx, leadSessionId)
129
+ const subagents = service(ctx, 'subagents')
130
+ if (subagents !== undefined && typeof subagents.interrupt === 'function') {
131
+ if (leadAgent === undefined) {
132
+ logSystem('[team-admin] 无法中断 ' + memberId + ':Lead Agent 未加载,缺少授权对象')
133
+ } else {
134
+ try {
135
+ subagents.interrupt(memberId, { kind: 'ancestor', agent: leadAgent })
136
+ } catch (error) {
137
+ logSystem('[team-admin] 中断 ' + memberId + ' 失败:' + String(error?.message ?? error))
138
+ }
139
+ }
140
+ }
141
+ const deadline = Date.now() + timeoutMs
142
+ for (;;) {
143
+ if (liveAgent(ctx, memberId)?.status !== 'running') return true
144
+ if (Date.now() >= deadline) break
145
+ await delay(STOP_POLL_MS)
146
+ }
147
+ throw new AdminError('成员仍在运行,已尝试中断,请稍后重试', 'running', 409)
148
+ }
149
+
150
+ /**
151
+ * 删除一个团队成员:停会话 → 子会话目录进回收站 → 写墓碑。
152
+ * @param {object} ctx - 宿主上下文(sessionPersistence / agents / subagents 均可选)。
153
+ * @param {{sessionId: string, name: string, deleteSession?: boolean}} request - 根会话、成员名、是否清理子会话(默认 true)。
154
+ * @param {{store?: object, now?: number|(() => number), logSystem?: (line: string) => void, interruptTimeoutMs?: number}} [options]
155
+ * store 为墓碑存储;now 为墓碑时间戳;interruptTimeoutMs 为停止等待上限(默认 15s)。
156
+ * @returns {Promise<{name: string, id: string, stopped: boolean, sessionDeleted: boolean, trashPath?: string, dismissedAt: number}>} 删除结果。
157
+ * @throws {AdminError} not-found / bad-session / lead / provisioning / running / system。
158
+ */
159
+ export async function dismissMember(ctx, request, options = {}) {
160
+ const { sessionId, name } = request ?? {}
161
+ const deleteSession = request?.deleteSession !== false
162
+ const { store, logSystem = () => {}, interruptTimeoutMs = DEFAULT_INTERRUPT_TIMEOUT_MS } = options ?? {}
163
+ const stamp = typeof options?.now === 'function' ? Number(options.now()) : Number.isFinite(options?.now) ? Number(options.now) : Date.now()
164
+
165
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof name !== 'string' || name === '') {
166
+ throw new AdminError('缺少 sessionId 或 name', 'bad-request', 400)
167
+ }
168
+ if (name === 'lead') throw new AdminError('不能删除 Lead', 'lead', 400)
169
+ if (store === undefined || store === null) throw new AdminError('墓碑存储不可用', 'system', 500)
170
+
171
+ const persistence = service(ctx, 'sessionPersistence')
172
+ if (persistence === undefined || typeof persistence.stat !== 'function') {
173
+ throw new AdminError('宿主会话服务不可用,无法删除成员', 'system', 500)
174
+ }
175
+ const snapshot = await statSnapshot(persistence, sessionId)
176
+ if (snapshot === undefined) throw new AdminError('会话不存在', 'not-found', 404)
177
+ if (snapshot.header.parentSession !== undefined) {
178
+ throw new AdminError('只能管理根会话的团队', 'bad-session', 400)
179
+ }
180
+
181
+ const log = await readSessionLog(persistence, sessionId)
182
+ const roster = parseRoster(log.events)
183
+ const member = roster.members.find((candidate) => candidate.name === name)
184
+ if (member === undefined) throw new AdminError('团队成员不存在', 'not-found', 404)
185
+ const memberId = String(member.id)
186
+
187
+ // 幂等:已墓碑的成员直接复用原记录,不重复中断 / 移动。
188
+ if (typeof store.listDismissed === 'function') {
189
+ const existing = (await store.listDismissed(sessionId)).find((row) => row.id === memberId)
190
+ if (existing !== undefined) {
191
+ return {
192
+ name: existing.name === undefined ? name : existing.name,
193
+ id: memberId,
194
+ stopped: false,
195
+ sessionDeleted: existing.sessionDeleted === true,
196
+ ...typeof existing.trashPath === 'string' ? { trashPath: existing.trashPath } : {},
197
+ dismissedAt: existing.dismissedAt,
198
+ }
199
+ }
200
+ }
201
+
202
+ if (member.phase === 'provisioning') {
203
+ throw new AdminError('成员正在创建中,请稍后再试', 'provisioning', 409)
204
+ }
205
+
206
+ const stopped = await stopMember(ctx, { leadSessionId: sessionId, memberId, timeoutMs: interruptTimeoutMs, logSystem })
207
+
208
+ let sessionDeleted = false
209
+ let trashPath
210
+ if (deleteSession) {
211
+ const child = await statSnapshot(persistence, memberId)
212
+ const locationPath = child?.location?.path
213
+ if (typeof locationPath !== 'string' || locationPath === '') {
214
+ logSystem('[team-admin] 成员 ' + memberId + ' 没有可移动的会话产物(未落盘或已清理)')
215
+ } else {
216
+ const source = await resolveSource(locationPath, memberId)
217
+ if (source === undefined) {
218
+ logSystem('[team-admin] 成员 ' + memberId + ' 的会话产物已不存在:' + locationPath)
219
+ } else {
220
+ try {
221
+ trashPath = await moveSessionArtifact({
222
+ sourcePath: source.path,
223
+ isDirectory: source.isDirectory,
224
+ trashRoot: join(store.dataDir, TRASH_DIR, sessionId),
225
+ dirName: memberId + '-' + stamp,
226
+ })
227
+ sessionDeleted = true
228
+ } catch (error) {
229
+ logSystem('[team-admin] 移动成员 ' + memberId + ' 的会话失败:' + String(error?.stack ?? error))
230
+ throw new AdminError('成员会话移动失败,请检查服务端日志', 'system', 500)
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ const record = await store.record(sessionId, memberId, {
237
+ name: isRecord(member) && typeof member.name === 'string' ? member.name : name,
238
+ dismissedAt: stamp,
239
+ sessionDeleted,
240
+ ...trashPath === undefined ? {} : { trashPath },
241
+ })
242
+ return {
243
+ name: record.name,
244
+ id: memberId,
245
+ stopped,
246
+ sessionDeleted: record.sessionDeleted === true,
247
+ ...typeof record.trashPath === 'string' ? { trashPath: record.trashPath } : {},
248
+ dismissedAt: record.dismissedAt,
249
+ }
250
+ }
package/src/error.mjs ADDED
@@ -0,0 +1,16 @@
1
+ /** 本插件统一的业务错误:带机器可读 code 与 HTTP 状态。 */
2
+
3
+ /** 业务错误。code 供日志/前端分支使用,message 中文直出。 */
4
+ export class AdminError extends Error {
5
+ /**
6
+ * @param {string} message - 面向用户的中文文案。
7
+ * @param {string} [code] - 机器可读错误码。
8
+ * @param {number} [status] - HTTP 状态码。
9
+ */
10
+ constructor(message, code = 'system', status = 400) {
11
+ super(message)
12
+ this.name = 'AdminError'
13
+ this.code = code
14
+ this.status = status
15
+ }
16
+ }
package/src/index.js ADDED
@@ -0,0 +1,64 @@
1
+ // dsh-team-admin 宿主半区入口:给「智能体团队」补管理接口 /api/team-admin/*。
2
+ // 只读会话持久化与 live Agent 状态,不依赖内置 agent-team 的私有实现;
3
+ // 删除动作 = 停会话 → 子会话目录进回收站 → 写墓碑(内置 roster 是 append-only,记录仍在)。
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { createApi } from './api.mjs'
8
+ import { dismissMember } from './dismiss.mjs'
9
+ import { openStore } from './store.mjs'
10
+ import { listTeamSessions, readTeam } from './team-read.mjs'
11
+
12
+ export const name = 'team-admin'
13
+ export const inject = ['webServer']
14
+
15
+ /** 数据目录环境变量(测试注入临时目录)。 */
16
+ const DATA_DIR_ENV = 'DSH_TEAM_ADMIN_DATA_DIR'
17
+ /** 路由前缀:webServer 只支持 exact/prefix 注册。 */
18
+ const ROUTE_PREFIX = '/api/team-admin'
19
+ /** 与 package.json 版本保持一致(发布前由测试校验)。 */
20
+ const PLUGIN_VERSION = '0.1.1'
21
+
22
+ /**
23
+ * 解析插件数据目录。
24
+ * @param {NodeJS.ProcessEnv} [env] - 进程环境。
25
+ * @param {string} [home] - 用户主目录。
26
+ * @returns {string} 数据目录绝对路径。
27
+ */
28
+ export function resolveDataDir(env = process.env, home = homedir()) {
29
+ const override = env[DATA_DIR_ENV]
30
+ if (typeof override === 'string' && override.trim() !== '') return override.trim()
31
+ return join(home, '.dsh', 'team-admin')
32
+ }
33
+
34
+ /**
35
+ * 装配插件:惰性建 store,注册一条 prefix 路由。
36
+ * @param {object} ctx - Cordis 插件上下文。
37
+ */
38
+ export function apply(ctx) {
39
+ const dataDir = resolveDataDir()
40
+ let storePromise = null
41
+ const getStore = () => {
42
+ if (storePromise === null) {
43
+ storePromise = openStore({ dir: dataDir }).catch((error) => {
44
+ storePromise = null
45
+ throw error
46
+ })
47
+ }
48
+ return storePromise
49
+ }
50
+ const logSystem = (line) => {
51
+ if (typeof ctx.logger?.warn === 'function') ctx.logger.warn(line)
52
+ }
53
+ const handler = createApi({
54
+ ctx,
55
+ getStore,
56
+ version: PLUGIN_VERSION,
57
+ logSystem,
58
+ services: { listTeamSessions, readTeam, dismissMember },
59
+ })
60
+ ctx.effect(
61
+ () => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }),
62
+ 'team-admin routes',
63
+ )
64
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,175 @@
1
+ // 墓碑存储:state.json 单文件 + 进程内写链。
2
+ // 写 = 同目录临时文件 + rename(原子替换);坏 JSON / 未知 version 改名 .corrupt-<ts> 后按空状态处理。
3
+
4
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+
7
+ /** 当前状态文件格式版本;其他版本一律按损坏处理。 */
8
+ const STATE_VERSION = 1
9
+ /** 状态文件名(位于 dataDir 下)。 */
10
+ const STATE_FILE = 'state.json'
11
+
12
+ /** 空状态:无任何墓碑。 */
13
+ function emptyState() {
14
+ return { version: STATE_VERSION, dismissed: {} }
15
+ }
16
+
17
+ /** 普通对象判定(排除 null / 数组)。 */
18
+ function isRecord(value) {
19
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
20
+ }
21
+
22
+ /** 规范化单条墓碑记录,剔除未知字段并补齐默认值。 */
23
+ function normalizeRecord(raw) {
24
+ const row = {
25
+ name: typeof raw.name === 'string' ? raw.name : '',
26
+ dismissedAt: Number.isFinite(raw.dismissedAt) ? raw.dismissedAt : Date.now(),
27
+ sessionDeleted: raw.sessionDeleted === true,
28
+ }
29
+ if (typeof raw.trashPath === 'string' && raw.trashPath !== '') row.trashPath = raw.trashPath
30
+ return row
31
+ }
32
+
33
+ /**
34
+ * 校验并规范化整份状态。
35
+ * @param {unknown} raw - 解析后的 JSON 值。
36
+ * @returns {{version: number, dismissed: Record<string, Record<string, object>>}|undefined} 合法状态,否则 undefined。
37
+ */
38
+ function normalizeState(raw) {
39
+ if (!isRecord(raw) || raw.version !== STATE_VERSION) return undefined
40
+ if (!isRecord(raw.dismissed)) return undefined
41
+ const dismissed = {}
42
+ for (const [leadId, bucket] of Object.entries(raw.dismissed)) {
43
+ if (!isRecord(bucket)) continue
44
+ const members = {}
45
+ for (const [memberId, record] of Object.entries(bucket)) {
46
+ if (isRecord(record)) members[memberId] = normalizeRecord(record)
47
+ }
48
+ dismissed[leadId] = members
49
+ }
50
+ return { version: STATE_VERSION, dismissed }
51
+ }
52
+
53
+ /** 深拷贝一条墓碑记录,附带 memberId 作为 id。 */
54
+ function toRow(memberId, record) {
55
+ return { id: memberId, ...record }
56
+ }
57
+
58
+ /**
59
+ * 打开(必要时创建)一个墓碑存储。
60
+ * @param {{dir: string}} options - dir 为数据目录(不要求已存在)。
61
+ * @returns {Promise<{readAll: () => Promise<{version: number, dismissed: object}>, listDismissed: (leadId: string) => Promise<object[]>, record: (leadId: string, memberId: string, row: object) => Promise<object>, dataDir: string}>} 存储句柄。
62
+ */
63
+ export async function openStore({ dir }) {
64
+ const dataDir = dir
65
+ const file = join(dir, STATE_FILE)
66
+ const tmpFile = file + '.tmp'
67
+ let loadPromise
68
+ let state
69
+ // 写链:同一进程内所有写串行,避免 tmp 文件互踩,并保证磁盘顺序与调用顺序一致。
70
+ let chain = Promise.resolve()
71
+
72
+ /** 把损坏文件改名隔离,失败不影响按空状态继续。 */
73
+ async function quarantine() {
74
+ try {
75
+ await rename(file, file + '.corrupt-' + Date.now())
76
+ } catch {
77
+ // 文件已不存在或不可改名:隔离失败时仍按空状态服务,避免整条路由不可用。
78
+ }
79
+ }
80
+
81
+ /** 读取 state.json(只加载一次);缺失 → 空状态,损坏 / 未知版本 → 隔离后空状态。 */
82
+ async function load() {
83
+ let text
84
+ try {
85
+ text = await readFile(file, 'utf8')
86
+ } catch (error) {
87
+ if (error.code === 'ENOENT') return emptyState()
88
+ throw error
89
+ }
90
+ let parsed
91
+ try {
92
+ parsed = JSON.parse(text)
93
+ } catch {
94
+ await quarantine()
95
+ return emptyState()
96
+ }
97
+ const normalized = normalizeState(parsed)
98
+ if (normalized === undefined) {
99
+ await quarantine()
100
+ return emptyState()
101
+ }
102
+ return normalized
103
+ }
104
+
105
+ /** 惰性加载状态(并发调用共享同一次加载;失败不缓存,下次重试)。 */
106
+ function ensureLoaded() {
107
+ loadPromise ??= load().then((value) => {
108
+ state = value
109
+ return value
110
+ }, (error) => {
111
+ loadPromise = undefined
112
+ throw error
113
+ })
114
+ return loadPromise
115
+ }
116
+
117
+ /** 原子落盘:写临时文件后 rename 覆盖。 */
118
+ async function persist() {
119
+ await mkdir(dataDir, { recursive: true })
120
+ await writeFile(tmpFile, JSON.stringify(state, null, 2), 'utf8')
121
+ await rename(tmpFile, file)
122
+ }
123
+
124
+ /** 在写链上执行一次「读状态 → 变更 → 落盘」,返回值即调用方结果。 */
125
+ function enqueue(task) {
126
+ const result = chain.then(async () => {
127
+ await ensureLoaded()
128
+ return task()
129
+ })
130
+ chain = result.then(() => undefined, () => undefined)
131
+ return result
132
+ }
133
+
134
+ return {
135
+ dataDir,
136
+ /** 读取全部墓碑(按 leadSessionId / memberId 分组,调用方拿到的是副本)。 */
137
+ async readAll() {
138
+ await ensureLoaded()
139
+ const dismissed = {}
140
+ for (const [leadId, bucket] of Object.entries(state.dismissed)) {
141
+ dismissed[leadId] = {}
142
+ for (const [memberId, record] of Object.entries(bucket)) {
143
+ dismissed[leadId][memberId] = toRow(memberId, record)
144
+ }
145
+ }
146
+ return { version: state.version, dismissed }
147
+ },
148
+ /** 列出某根会话下已删除的成员,按删除时间降序。 */
149
+ async listDismissed(leadId) {
150
+ await ensureLoaded()
151
+ const bucket = state.dismissed[leadId]
152
+ if (bucket === undefined) return []
153
+ return Object.entries(bucket)
154
+ .map(([memberId, record]) => toRow(memberId, record))
155
+ .sort((left, right) => right.dismissedAt - left.dismissedAt)
156
+ },
157
+ /**
158
+ * 记录一条墓碑;同一成员已存在时保留原记录(幂等),返回最终生效的记录。
159
+ * @param {string} leadId - 根会话 id。
160
+ * @param {string} memberId - 成员会话 id。
161
+ * @param {{name: string, dismissedAt: number, sessionDeleted: boolean, trashPath?: string}} row - 墓碑内容。
162
+ */
163
+ async record(leadId, memberId, row) {
164
+ return enqueue(async () => {
165
+ const bucket = state.dismissed[leadId] ?? (state.dismissed[leadId] = {})
166
+ const existing = bucket[memberId]
167
+ if (existing !== undefined) return toRow(memberId, existing)
168
+ const normalized = normalizeRecord(row)
169
+ bucket[memberId] = normalized
170
+ await persist()
171
+ return toRow(memberId, normalized)
172
+ })
173
+ },
174
+ }
175
+ }