dsh-collab-workbench 0.0.0-stage → 0.1.2
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.
Potentially problematic release.
This version of dsh-collab-workbench might be problematic. Click here for more details.
- package/LICENSE +202 -0
- package/README.md +112 -2
- package/client.js +2136 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +1 -0
- package/index.js +86 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +60 -3
- package/scripts/build-approvals.mjs +15 -0
- package/scripts/check.mjs +17 -0
- package/src/client/approval-ui.js +117 -0
- package/src/client/update-ui.js +73 -0
- package/src/host/agents.js +235 -0
- package/src/host/api.js +264 -0
- package/src/host/approvals-fold.js +34 -0
- package/src/host/approvals.js +36 -0
- package/src/host/channels.js +509 -0
- package/src/host/delivery.js +95 -0
- package/src/host/execution-engine.js +417 -0
- package/src/host/execution-tools.js +65 -0
- package/src/host/execution.js +274 -0
- package/src/host/files.js +89 -0
- package/src/host/groups.js +501 -0
- package/src/host/ledger.js +177 -0
- package/src/host/memory.js +175 -0
- package/src/host/policy.js +43 -0
- package/src/host/tasks.js +117 -0
- package/src/host/tools.js +491 -0
- package/src/host/update.js +135 -0
- package/src/host/verify.js +44 -0
- package/src/host/work-item-sync.js +101 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// groups.js —— 群组管理(V3:通用工作台,不预设任何文件模板)。
|
|
2
|
+
// 群组名与同名工作目录一一对应;目录初始为空,由 AI 团队根据任务类型自行创建合适的文件。
|
|
3
|
+
// 改名时目录同步改名(P6)。
|
|
4
|
+
import { mkdir, readdir, realpath, rename, rm } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
// 名称非法字符:路径分隔、控制字符。
|
|
8
|
+
const NAME_BAD = /[\/\\\u0000-\u001f]/
|
|
9
|
+
|
|
10
|
+
export function install(rt) {
|
|
11
|
+
const { ctx } = rt
|
|
12
|
+
rt.movingGroups = new Set()
|
|
13
|
+
const moveDirectory = async (group, newName) => {
|
|
14
|
+
const runs = rt.executionRuns?.() ?? []
|
|
15
|
+
if (runs.some(r => r.groupId === group.id && !['completed','cancelled'].includes(r.status) && (r.sessionId || ['starting','running'].includes(r.status)))) throw new Error('请先完成或停止任务执行,再移动群组工作目录')
|
|
16
|
+
if (Object.values(group.sessions ?? {}).some(e => ctx.get?.('agents')?.get(e.id)?.status === 'running')) throw new Error('群成员仍在工作,请先停止后改名')
|
|
17
|
+
rt.movingGroups.add(group.id)
|
|
18
|
+
try {
|
|
19
|
+
const entries = await readdir(rt.groupsRoot()).catch(() => [])
|
|
20
|
+
if (entries.includes(newName) && newName !== group.name) throw new Error(`目标目录「${newName}」已存在,无法改名`)
|
|
21
|
+
const newDir = rt.groupDir(newName)
|
|
22
|
+
await rename(group.dir, newDir)
|
|
23
|
+
group.archivedSessions = [...new Set([...(group.archivedSessions ?? []), ...Object.values(group.sessions ?? {}).map(e => e.id)])]
|
|
24
|
+
group.sessions = {}
|
|
25
|
+
group.workspaceId = null
|
|
26
|
+
group.dir = newDir
|
|
27
|
+
rt.touch()
|
|
28
|
+
return newDir
|
|
29
|
+
} finally { rt.movingGroups.delete(group.id) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- 命名校验:非空、无路径分隔/控制字符 ----
|
|
33
|
+
rt.validateGroupName = (rawName) => {
|
|
34
|
+
const name = String(rawName ?? '').trim()
|
|
35
|
+
if (!name) throw new Error('群组名称不能为空')
|
|
36
|
+
if (NAME_BAD.test(name)) throw new Error('群组名称不能包含 /、\\ 或控制字符')
|
|
37
|
+
return name
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- 工作目录 ----
|
|
41
|
+
rt.groupsRoot = () => rt.config?.groupsRoot ?? join(rt.home, 'groups')
|
|
42
|
+
|
|
43
|
+
rt.groupDir = (name) => join(rt.groupsRoot(), name)
|
|
44
|
+
|
|
45
|
+
// ---- 群工作区管理(DSH 原生工作区,成员会话归类展示) ----
|
|
46
|
+
// 每个群目录注册为一个 DSH 工作区(以群名命名),成员会话创建在该工作区下。
|
|
47
|
+
// DSH 侧栏显示:工作区名(群名)→ 里面是该群全部成员会话。
|
|
48
|
+
|
|
49
|
+
// 安全按 ID 查工作区:DSH 真实 API 是 get(id)(没有 resolve——旧代码调
|
|
50
|
+
// resolve 的地方一直靠 try/catch 静默吞掉 TypeError,等于从未生效)。
|
|
51
|
+
// 防御性设计:get 缺失时回退 list() 过滤;任何异常一律按"未找到"处理。
|
|
52
|
+
rt.findWorkspaceById = async (id) => {
|
|
53
|
+
if (!id) return null
|
|
54
|
+
const { workspaceRegistry } = ctx
|
|
55
|
+
try {
|
|
56
|
+
const ws = await workspaceRegistry.get?.(id)
|
|
57
|
+
if (ws) return ws
|
|
58
|
+
} catch { /* 忽略 */ }
|
|
59
|
+
try {
|
|
60
|
+
const list = await workspaceRegistry.list?.() ?? []
|
|
61
|
+
return list.find(w => w && w.id === id) ?? null
|
|
62
|
+
} catch { return null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// workspaceOwned:工作区「所有权」标记。只有插件自己 create 的工作区才
|
|
66
|
+
// 拥有所有权(删群时允许删除工作区记录,避免侧栏残留僵尸空工作区)。
|
|
67
|
+
// 按路径收养的已有工作区一律视为他人所有——自定义目录可能正指向用户
|
|
68
|
+
// 已注册为工作区的项目目录,收养后删群不得动它的记录。
|
|
69
|
+
// 规范化路径(realpath);目录不存在返回 null。
|
|
70
|
+
rt.canonicalPath = async (p) => {
|
|
71
|
+
try { return await realpath(String(p)) } catch { return null }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
rt.ensureGroupWorkspace = async (group) => {
|
|
75
|
+
const { workspaceRegistry } = ctx
|
|
76
|
+
// 已有有效的 workspaceId → 直接用(所有权标记沿用台账记录)。
|
|
77
|
+
if (group.workspaceId) {
|
|
78
|
+
const ws = await rt.findWorkspaceById(group.workspaceId)
|
|
79
|
+
if (ws) return group.workspaceId
|
|
80
|
+
}
|
|
81
|
+
// 尝试按路径找已有工作区(群目录改名后 id 失效的恢复路径)。
|
|
82
|
+
if (group.dir) {
|
|
83
|
+
const existing = await workspaceRegistry.resolveByPath(group.dir).catch(() => undefined)
|
|
84
|
+
if (existing) {
|
|
85
|
+
// ⚠️ 仅复用、不取得所有权:能走到这里说明台账里的 workspaceId 已失效,
|
|
86
|
+
// 路径上现存的是另一条(或来历不明的)工作区记录——一律视为收养。
|
|
87
|
+
rt.updateGroupRecord(group.id, { workspaceId: existing.id, workspaceOwned: false })
|
|
88
|
+
return existing.id
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// 新建工作区(以群名命名,指向群目录)→ 插件拥有所有权。
|
|
92
|
+
if (!group.dir) return null
|
|
93
|
+
const ws = await workspaceRegistry.create(group.dir, group.name)
|
|
94
|
+
rt.updateGroupRecord(group.id, { workspaceId: ws.id, workspaceOwned: true })
|
|
95
|
+
return ws.id
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 创建同名目录(空目录,不预设任何文件——团队根据任务类型自行创建)。
|
|
99
|
+
rt.scaffoldGroupDir = async (name) => {
|
|
100
|
+
const dir = rt.groupDir(name)
|
|
101
|
+
await mkdir(dir, { recursive: true })
|
|
102
|
+
return dir
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 校验成员列表:存在且启用;返回去重 id 数组。
|
|
106
|
+
const validateMembers = (memberIds) => {
|
|
107
|
+
const ids = [...new Set((memberIds ?? []).map(String))]
|
|
108
|
+
if (ids.length === 0) throw new Error('群组至少需要 1 名成员')
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const role = rt.getRole(id)
|
|
111
|
+
if (!role) throw new Error(`成员 ${id} 不存在`)
|
|
112
|
+
if (role.status === 'disabled') throw new Error(`成员「${role.name}」已停用`)
|
|
113
|
+
}
|
|
114
|
+
return ids
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 校验管理员 ⊆ 成员且存在。
|
|
118
|
+
const validateAdmins = (admins, memberIds) => {
|
|
119
|
+
const ids = [...new Set((admins ?? []).map(String))]
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
if (!memberIds.includes(id)) throw new Error(`管理员 ${id} 必须同时是群组成员`)
|
|
122
|
+
if (!rt.getRole(id)) throw new Error(`管理员 ${id} 不存在`)
|
|
123
|
+
}
|
|
124
|
+
return ids
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- 建群 ----
|
|
128
|
+
// name 群组名称(原样使用);admins 管理员 id 列表;notice 群公告;
|
|
129
|
+
// members 成员 id 数组(含管理员);
|
|
130
|
+
// customDir 工作目录(用户入口必填——UI 的 groups.create 与 dwb_create_group
|
|
131
|
+
// 都强制传入:成员直接在其中读取资料/存放成果物,群附件也存于此;
|
|
132
|
+
// 不存在会自动创建;删除群组时该目录保留);
|
|
133
|
+
// withDir 仅内部路径使用(dwb_command_new_project 快捷命令派生默认目录)。
|
|
134
|
+
rt.createGroup = async ({ name: rawName, prefix: legacyPrefix, admins = [], notice = '', members, withDir = true, customDir }) => {
|
|
135
|
+
const name = rt.validateGroupName(rawName ?? legacyPrefix)
|
|
136
|
+
if ((rt.listGroups() ?? []).some(g => g.name === name)) {
|
|
137
|
+
throw new Error(`群组「${name}」已存在`)
|
|
138
|
+
}
|
|
139
|
+
const memberIds = validateMembers(members)
|
|
140
|
+
const adminIds = validateAdmins(admins, memberIds)
|
|
141
|
+
|
|
142
|
+
// 工作目录:自定义目录优先(存在即用、缺失则建);否则默认目录。
|
|
143
|
+
// ⚠️ 自定义目录必须规范化(realpath)后入账:DSH 目录选择器可能返回
|
|
144
|
+
// 带尾部斜杠的路径,而注册表 create/resolveByPath 内部按 realpath
|
|
145
|
+
// 规范化——不规范化会导致 group.dir ≠ workspace.path 恒成立,
|
|
146
|
+
// 删群时的所有权判定永远失败(工作区"永生"bug 的根因)。
|
|
147
|
+
let dir = null
|
|
148
|
+
let isCustom = false
|
|
149
|
+
if (customDir && String(customDir).trim()) {
|
|
150
|
+
dir = String(customDir).trim()
|
|
151
|
+
isCustom = true
|
|
152
|
+
await mkdir(dir, { recursive: true }).catch(() => {
|
|
153
|
+
throw new Error(`无法访问目录 ${dir}`)
|
|
154
|
+
})
|
|
155
|
+
dir = await realpath(dir).catch(() => dir) // 规范化(去尾部斜杠/解析符号链接)
|
|
156
|
+
} else if (withDir) {
|
|
157
|
+
dir = rt.groupDir(name)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const group = {
|
|
161
|
+
id: rt.nextId('group'),
|
|
162
|
+
name,
|
|
163
|
+
prefix: name, // 兼容字段:V2 起等于群组名
|
|
164
|
+
members: memberIds,
|
|
165
|
+
admins: adminIds,
|
|
166
|
+
notice: String(notice ?? '').trim(),
|
|
167
|
+
dir,
|
|
168
|
+
isCustomDir: isCustom, // 标记:删除群组时自定义目录及其文件保留
|
|
169
|
+
status: 'active',
|
|
170
|
+
createdAt: Date.now(),
|
|
171
|
+
updatedAt: Date.now(),
|
|
172
|
+
}
|
|
173
|
+
rt.addGroup(group)
|
|
174
|
+
if (dir && !isCustom) {
|
|
175
|
+
await rt.scaffoldGroupDir(name)
|
|
176
|
+
}
|
|
177
|
+
return group
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- 群组编辑(名称 / 公告 / 管理员 / 成员) ----
|
|
181
|
+
// 名称变化会同步改名目录;成员/管理员按建群规则校验。
|
|
182
|
+
rt.updateGroupMeta = async (id, { name: rawName, notice, admins, members }) => {
|
|
183
|
+
const group = rt.getGroup(id)
|
|
184
|
+
if (!group) throw new Error(`群组 ${id} 不存在`)
|
|
185
|
+
const patch = { updatedAt: Date.now() }
|
|
186
|
+
|
|
187
|
+
let newName = group.name
|
|
188
|
+
if (rawName !== undefined && String(rawName).trim() !== group.name) {
|
|
189
|
+
newName = rt.validateGroupName(rawName)
|
|
190
|
+
if ((rt.listGroups() ?? []).some(g => g.id !== id && g.name === newName)) {
|
|
191
|
+
throw new Error(`群组「${newName}」已存在`)
|
|
192
|
+
}
|
|
193
|
+
patch.name = newName
|
|
194
|
+
patch.prefix = newName
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let nextMembers = group.members ?? []
|
|
198
|
+
if (members !== undefined) nextMembers = validateMembers(members)
|
|
199
|
+
|
|
200
|
+
let nextAdmins = group.admins ?? []
|
|
201
|
+
if (admins !== undefined) nextAdmins = validateAdmins(admins, nextMembers)
|
|
202
|
+
else nextAdmins = (group.admins ?? []).filter(a => nextMembers.includes(a))
|
|
203
|
+
patch.members = nextMembers
|
|
204
|
+
patch.admins = nextAdmins
|
|
205
|
+
|
|
206
|
+
if (notice !== undefined) patch.notice = String(notice ?? '').trim()
|
|
207
|
+
|
|
208
|
+
// 名称变化 → 默认目录同步改名(防覆盖);自定义目录不随群改名搬迁。
|
|
209
|
+
if (patch.name && group.dir && !group.isCustomDir) {
|
|
210
|
+
patch.dir = await moveDirectory(group, newName)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const updated = rt.updateGroupRecord(group.id, patch)
|
|
214
|
+
for (const roleId of group.members.filter(id => !nextMembers.includes(id))) await rt.stopPerson?.(group.id, roleId)
|
|
215
|
+
for (const task of rt.listTasks(group.id)) {
|
|
216
|
+
const assignees = task.assignees.filter(id => nextMembers.includes(id))
|
|
217
|
+
if (assignees.length !== task.assignees.length) {
|
|
218
|
+
const next = rt.updateTaskRecord(task.id,{assignees})
|
|
219
|
+
await rt.taskChanged?.(next,task)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
await rt.saveAll?.()
|
|
223
|
+
return updated
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- 改名(目录同步 + 工作区标题同步) ----
|
|
227
|
+
rt.renameGroup = async (id, newNameRaw) => {
|
|
228
|
+
const group = rt.getGroup(id)
|
|
229
|
+
if (!group) throw new Error(`群组 ${id} 不存在`)
|
|
230
|
+
const newName = rt.validateGroupName(newNameRaw)
|
|
231
|
+
if ((rt.listGroups() ?? []).some(g => g.id !== id && g.name === newName)) {
|
|
232
|
+
throw new Error(`群组「${newName}」已存在`)
|
|
233
|
+
}
|
|
234
|
+
const oldDir = group.dir
|
|
235
|
+
let newDir = null
|
|
236
|
+
// 默认目录随改名移动;自定义目录是用户的项目目录,不随群改名搬迁。
|
|
237
|
+
if (oldDir && !group.isCustomDir) {
|
|
238
|
+
newDir = await moveDirectory(group, newName)
|
|
239
|
+
}
|
|
240
|
+
const updated = rt.updateGroupRecord(id, {
|
|
241
|
+
name: newName,
|
|
242
|
+
prefix: newName,
|
|
243
|
+
dir: newDir ?? oldDir,
|
|
244
|
+
updatedAt: Date.now(),
|
|
245
|
+
})
|
|
246
|
+
// 同步工作区标题(群名变了 → DSH 侧栏工作区名跟着变)。
|
|
247
|
+
if (group.workspaceId) {
|
|
248
|
+
try { await ctx.workspaceRegistry.rename(group.workspaceId, newName) } catch { /* 忽略 */ }
|
|
249
|
+
}
|
|
250
|
+
return updated
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- 删除群组 ----
|
|
254
|
+
// ⚠️ 安全红线(2026-10-16 事故修复):会话数据只按「精确会话 ID」删除,
|
|
255
|
+
// 且 ID 必须来自本群台账(group.sessions + group.archivedSessions)。
|
|
256
|
+
// 旧版按 groupDir 尾段的编码子串模糊匹配 ~/.dsh/sessions/ 下的工作区
|
|
257
|
+
// 目录名——群名(即群目录尾段)与任何真实工作区目录名片段重合时,
|
|
258
|
+
// 该工作区下全部会话(含用户 GUI 会话)会被整目录 rm -rf。
|
|
259
|
+
// 例如群名"随机问答"→ 编码 ~968F~673A~95EE~7B54 → 子串命中
|
|
260
|
+
// --Users-...-~968F~673A~95EE~7B54-- → 里面所有会话被误删。
|
|
261
|
+
rt.dshSessionsRoots = () => {
|
|
262
|
+
const userHome = process.env.HOME ?? ''
|
|
263
|
+
const roots = []
|
|
264
|
+
if (rt.config?.sessionsRoot) roots.push(rt.config.sessionsRoot) // 测试注入
|
|
265
|
+
if (rt.home) roots.push(join(rt.home, '..', 'sessions'))
|
|
266
|
+
if (userHome) roots.push(join(userHome, '.dsh', 'sessions'))
|
|
267
|
+
return [...new Set(roots)]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
rt.purgeSessionFiles = async (sessionIds) => {
|
|
271
|
+
if (!sessionIds || sessionIds.size === 0) return
|
|
272
|
+
for (const root of rt.dshSessionsRoots()) {
|
|
273
|
+
try {
|
|
274
|
+
const wsDirs = await readdir(root).catch(() => [])
|
|
275
|
+
for (const wsDir of wsDirs) {
|
|
276
|
+
const wsPath = join(root, wsDir)
|
|
277
|
+
const sessDirs = await readdir(wsPath).catch(() => [])
|
|
278
|
+
let deleted = 0
|
|
279
|
+
let remaining = 0
|
|
280
|
+
for (const sessDir of sessDirs) {
|
|
281
|
+
// 仅精确命中台账中的会话 ID 才删除;其余一律不动。
|
|
282
|
+
if (sessionIds.has(sessDir)) {
|
|
283
|
+
await rm(join(wsPath, sessDir), { recursive: true, force: true }).catch(() => {})
|
|
284
|
+
deleted++
|
|
285
|
+
} else {
|
|
286
|
+
remaining++
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 仅当本轮确实删过且工作区已空时顺带清理空目录(不动未命中的工作区)。
|
|
290
|
+
if (deleted > 0 && remaining === 0) {
|
|
291
|
+
await rm(wsPath, { recursive: true, force: true }).catch(() => {})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch { /* 忽略 */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 保守幽灵清扫:只清除磁盘上确实不存在的归档记录,但保留 keepArchived(本次删除的会话)。
|
|
299
|
+
// 归档中的 ID 是"隐藏标记"——保留它们才能让侧栏保持隐藏状态。
|
|
300
|
+
rt.sweepGhostSessions = async (keepArchived = new Set()) => {
|
|
301
|
+
const userHome = process.env.HOME ?? ''
|
|
302
|
+
if (!userHome) return
|
|
303
|
+
const wsJsonPath = join(userHome, '.dsh', 'storages', 'workspace.json')
|
|
304
|
+
try {
|
|
305
|
+
const { readFile, writeFile } = await import('node:fs/promises')
|
|
306
|
+
const raw = await readFile(wsJsonPath, 'utf8').catch(() => null)
|
|
307
|
+
if (!raw) return
|
|
308
|
+
const data = JSON.parse(raw)
|
|
309
|
+
const archived = data?.global?.archivedSessionIds ?? []
|
|
310
|
+
// 扫描磁盘上实际存在的会话
|
|
311
|
+
const diskSessions = new Set()
|
|
312
|
+
const root = join(userHome, '.dsh', 'sessions')
|
|
313
|
+
const wsDirs = await readdir(root).catch(() => [])
|
|
314
|
+
for (const wsDir of wsDirs) {
|
|
315
|
+
const sess = await readdir(join(root, wsDir)).catch(() => [])
|
|
316
|
+
for (const sid of sess) diskSessions.add(sid)
|
|
317
|
+
}
|
|
318
|
+
const valid = archived.filter(sid => keepArchived.has(sid) || diskSessions.has(sid))
|
|
319
|
+
if (valid.length < archived.length) {
|
|
320
|
+
data.global.archivedSessionIds = valid
|
|
321
|
+
await writeFile(wsJsonPath, JSON.stringify(data, null, 2))
|
|
322
|
+
}
|
|
323
|
+
} catch { /* 忽略 */ }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 清除 DSH 侧栏会话注册表(session_projcache/sessions/*.json)。
|
|
327
|
+
// ⚠️ 安全红线:只按「精确会话 ID」删除(ID 来自本群台账)。
|
|
328
|
+
// 旧版按 cwd 前缀匹配(startsWith groupDir)过宽——群目录路径若是某个
|
|
329
|
+
// 更上层目录的前缀(或用户会话恰好开在群目录下),会误删他人记录。
|
|
330
|
+
rt.purgeSessionRegistry = async (sessionIds) => {
|
|
331
|
+
if (!sessionIds || sessionIds.size === 0) return
|
|
332
|
+
const userHome = process.env.HOME ?? ''
|
|
333
|
+
if (!userHome && !rt.config?.projcacheSessionsDir) return
|
|
334
|
+
const cacheDir = rt.config?.projcacheSessionsDir // 测试注入
|
|
335
|
+
?? join(userHome, '.dsh', 'storages', 'session_projcache', 'sessions')
|
|
336
|
+
try {
|
|
337
|
+
const files = await readdir(cacheDir).catch(() => [])
|
|
338
|
+
for (const file of files) {
|
|
339
|
+
if (!file.endsWith('.json')) continue
|
|
340
|
+
const sid = file.slice(0, -5)
|
|
341
|
+
if (sessionIds.has(sid)) {
|
|
342
|
+
await rm(join(cacheDir, file), { force: true }).catch(() => {})
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} catch { /* 忽略 */ }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
rt.deleteGroup = async (id) => {
|
|
349
|
+
const group = rt.getGroup(id)
|
|
350
|
+
if (!group) throw new Error(`群组 ${id} 不存在`)
|
|
351
|
+
group.status = 'deleting'
|
|
352
|
+
rt.touch()
|
|
353
|
+
await rt.saveAll?.()
|
|
354
|
+
const executionSessionIds = await rt.cancelGroupExecution?.(group.id) ?? []
|
|
355
|
+
// ① 收集本群会话 ID(fail-closed:只认台账)。
|
|
356
|
+
// 当前成员会话(group.sessions)+ 轮换归档的历史会话(group.archivedSessions)。
|
|
357
|
+
// 绝不从工作区记录的 sessionIds 收集——那里可能混有用户自己的 GUI 会话
|
|
358
|
+
// (用户在群目录下开过会话时,会话会挂到同一个工作区)。
|
|
359
|
+
const allSessionIds = new Set(executionSessionIds)
|
|
360
|
+
for (const [, entry] of Object.entries(group.sessions ?? {})) {
|
|
361
|
+
if (entry?.id) allSessionIds.add(entry.id)
|
|
362
|
+
}
|
|
363
|
+
for (const sid of group.archivedSessions ?? []) {
|
|
364
|
+
if (sid) allSessionIds.add(sid)
|
|
365
|
+
}
|
|
366
|
+
// ② 先停止运行中的 agent(如果会话正在工作,内存中的 agent 必须终止)。
|
|
367
|
+
for (const sid of allSessionIds) {
|
|
368
|
+
await rt.stopSessionAgent?.(group.id, sid).catch(() => null)
|
|
369
|
+
try {
|
|
370
|
+
const { agent } = await ctx.sessionController.resolveAgent(sid)
|
|
371
|
+
if (agent) {
|
|
372
|
+
try { agent.cancel?.({ kind: 'user' }) } catch { /* 忽略 */ }
|
|
373
|
+
try { await ctx.sessions?.flush?.(agent.session) } catch { /* 忽略 */ }
|
|
374
|
+
}
|
|
375
|
+
} catch { /* 忽略 */ }
|
|
376
|
+
}
|
|
377
|
+
// ③ 工作区所有权判定:只有「插件 create 的工作区」才允许删除工作区记录
|
|
378
|
+
// (否则删群后侧栏残留僵尸空工作区)。规则(fail-closed):
|
|
379
|
+
// - workspaceOwned=false(按路径收养)→ 保留;
|
|
380
|
+
// - workspaceOwned=true(插件 create)且路径吻合 → 删除;
|
|
381
|
+
// - 无标记的旧数据 → 默认目录按"路径吻合"判定,自定义目录一律保留
|
|
382
|
+
// (无法区分是插件新建还是收养的用户工作区)。
|
|
383
|
+
// ⚠️ 对齐 DSH 原生删除语义:WorkspaceCommands.delete 就是一句
|
|
384
|
+
// registry.delete,不检查工作区挂了哪些会话——曾加的"外来会话护栏"
|
|
385
|
+
// 比原生更保守,两次挡住删除的都是用户点开工作区时 DSH 自动产生的
|
|
386
|
+
// 零 token 空壳 GUI 草稿会话(用户不知情也无从处理)。删除注册不动
|
|
387
|
+
// 会话数据,DSH 会按需为存留会话重新归组。
|
|
388
|
+
let ownedWorkspaceId = group.workspaceId ?? null
|
|
389
|
+
if (ownedWorkspaceId && group.workspaceOwned === false) {
|
|
390
|
+
ownedWorkspaceId = null // 明确收养 → 只清会话引用,不删工作区记录
|
|
391
|
+
} else if (ownedWorkspaceId) {
|
|
392
|
+
const ws = await rt.findWorkspaceById(ownedWorkspaceId)
|
|
393
|
+
if (!ws) {
|
|
394
|
+
ownedWorkspaceId = null
|
|
395
|
+
} else {
|
|
396
|
+
// 路径比较按 realpath 规范化:workspace.path 由注册表规范化存储,
|
|
397
|
+
// 而历史台账里的 group.dir 可能带尾部斜杠(目录选择器返回值)或
|
|
398
|
+
// 含符号链接——直接字符串比较会恒不等,导致工作区永远删不掉。
|
|
399
|
+
const canonical = await rt.canonicalPath(group.dir) ?? group.dir
|
|
400
|
+
if (ws.path !== canonical && ws.path !== group.dir) {
|
|
401
|
+
ownedWorkspaceId = null
|
|
402
|
+
} else if (group.workspaceOwned !== true && group.isCustomDir) {
|
|
403
|
+
ownedWorkspaceId = null // 旧数据 + 自定义目录 → 保留
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// ④ 删除插件所建的工作区记录 —— 必须走 DSH 真实 API registry.delete:
|
|
408
|
+
// 它从注册表内存移除实体并自行持久化(语义:删注册、保留目录与会话日志)。
|
|
409
|
+
// ⚠️ 直改 workspace.json 对注册表内存不可见——退出/重启时内存状态回写
|
|
410
|
+
// 文件会把已删记录复活("删了又出现",2026-10-16 复现确认:文件 mtime
|
|
411
|
+
// 为删群时刻但记录带着旧的 updatedAt 复活)。旧注释"不用
|
|
412
|
+
// workspaceRegistry.delete 会覆盖清理"针对的是会话状态回写,对工作区
|
|
413
|
+
// 记录删除恰好相反——API 才是唯一不会被复活的路径。
|
|
414
|
+
if (ownedWorkspaceId) {
|
|
415
|
+
// registry.delete 返回 true=已删 / false=未知 ID;方法缺失时
|
|
416
|
+
// delete?.() 得到 undefined——只有显式 true 才算 API 成功,否则走兜底
|
|
417
|
+
// (P15-3 回归:旧判定 r !== false 会把 undefined 误判为成功)。
|
|
418
|
+
// 删后校验:即使返回 true 也复查注册表——API 静默失效时自纠兜底。
|
|
419
|
+
let apiDeleted = false
|
|
420
|
+
try {
|
|
421
|
+
const r = await ctx.workspaceRegistry.delete?.(ownedWorkspaceId)
|
|
422
|
+
apiDeleted = r === true && (await rt.findWorkspaceById(ownedWorkspaceId)) === null
|
|
423
|
+
} catch { apiDeleted = false }
|
|
424
|
+
if (!apiDeleted) {
|
|
425
|
+
// API 缺失/失败/删后仍存在 → 兜底直写文件(可能被内存回写复活,聊胜于无)
|
|
426
|
+
await rt.cleanWorkspaceRefs(new Set(), ownedWorkspaceId)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// ⑤ 直写 workspace.json 清理全局引用(archived/pinned)与其他工作区
|
|
430
|
+
// sessionIds 中的本群会话 ID(不再承担工作区记录删除)。
|
|
431
|
+
await rt.cleanWorkspaceRefs(allSessionIds, undefined)
|
|
432
|
+
// ⑤ 物理删除会话数据(磁盘上的会话目录)——仅按精确会话 ID。
|
|
433
|
+
await rt.purgeSessionFiles(allSessionIds)
|
|
434
|
+
// ⑥ 删除 DSH 侧栏会话注册表(session_projcache)——仅按精确会话 ID。
|
|
435
|
+
await rt.purgeSessionRegistry(allSessionIds)
|
|
436
|
+
// ⑦ 删除工作目录及全部文件(仅默认目录——自定义目录保留,可能是用户的主项目)。
|
|
437
|
+
if (group.dir && !group.isCustomDir) {
|
|
438
|
+
await rm(group.dir, { recursive: true, force: true }).catch(() => { /* 忽略 */ })
|
|
439
|
+
}
|
|
440
|
+
// ⑦ 删除关联任务(兼容旧数据:groupId 可能存的是群名而非群 ID)。
|
|
441
|
+
rt.state.tasks.items = (rt.state.tasks.items ?? []).filter(t =>
|
|
442
|
+
t.groupId !== group.id && t.groupId !== group.name
|
|
443
|
+
)
|
|
444
|
+
// ⑧ 从台账移除。
|
|
445
|
+
const index = rt.state.groups.items.findIndex(item => item.id === group.id)
|
|
446
|
+
if (index !== -1) rt.state.groups.items.splice(index, 1)
|
|
447
|
+
rt.touch()
|
|
448
|
+
await rt.saveAll?.()
|
|
449
|
+
return { deleted: true, name: group.name, dir: group.dir }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 直接修改 workspace.json(仿 delete-session.sh clean_ws):
|
|
453
|
+
// ① 从 archivedSessionIds / pinnedSessionIds / 所有 workspace.sessionIds
|
|
454
|
+
// 移除指定会话 ID
|
|
455
|
+
// ② 删除指定工作区记录本身(从 workspaceIds 和 tables.workspaces 中移除)
|
|
456
|
+
// 这会让 DSH 主进程立即感知会话被移除,侧栏随之刷新(无需重启)。
|
|
457
|
+
rt.cleanWorkspaceRefs = async (sessionIds, workspaceId) => {
|
|
458
|
+
const userHome = process.env.HOME ?? ''
|
|
459
|
+
if (!userHome && !rt.config?.workspaceJsonPath) return
|
|
460
|
+
// 如果既没有 sessionIds 也没有 workspaceId,无事可做
|
|
461
|
+
if ((!sessionIds || sessionIds.size === 0) && !workspaceId) return
|
|
462
|
+
const wsJsonPath = rt.config?.workspaceJsonPath // 测试注入
|
|
463
|
+
?? join(userHome, '.dsh', 'storages', 'workspace.json')
|
|
464
|
+
try {
|
|
465
|
+
const { readFile, writeFile, copyFile } = await import('node:fs/promises')
|
|
466
|
+
const raw = await readFile(wsJsonPath, 'utf8').catch(() => null)
|
|
467
|
+
if (!raw) return
|
|
468
|
+
// 先备份(仿 delete-session.sh:cp workspace.json workspace.json.bak)
|
|
469
|
+
await copyFile(wsJsonPath, wsJsonPath + '.bak').catch(() => {})
|
|
470
|
+
const data = JSON.parse(raw)
|
|
471
|
+
const g = data.global ?? (data.global = {})
|
|
472
|
+
const before = JSON.stringify(data)
|
|
473
|
+
// ① 移除会话 ID 引用
|
|
474
|
+
if (sessionIds && sessionIds.size > 0) {
|
|
475
|
+
g.archivedSessionIds = (g.archivedSessionIds ?? []).filter(sid => !sessionIds.has(sid))
|
|
476
|
+
g.pinnedSessionIds = (g.pinnedSessionIds ?? []).filter(sid => !sessionIds.has(sid))
|
|
477
|
+
for (const ws of Object.values(data.tables?.workspaces ?? {})) {
|
|
478
|
+
ws.sessionIds = (ws.sessionIds ?? []).filter(sid => !sessionIds.has(sid))
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// ② 删除工作区记录本身(完全替代 workspaceRegistry.delete,避免其副作用)
|
|
482
|
+
if (workspaceId) {
|
|
483
|
+
delete data.tables?.workspaces?.[workspaceId]
|
|
484
|
+
const wids = g.workspaceIds ?? []
|
|
485
|
+
g.workspaceIds = wids.filter(id => id !== workspaceId)
|
|
486
|
+
}
|
|
487
|
+
if (JSON.stringify(data) !== before) {
|
|
488
|
+
await writeFile(wsJsonPath, JSON.stringify(data, null, 2) + '\n')
|
|
489
|
+
}
|
|
490
|
+
} catch { /* 忽略 */ }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ledger 底层更新(避免与 ledger.updateGroup 递归冲突)。
|
|
494
|
+
rt.updateGroupRecord = (id, patch) => {
|
|
495
|
+
const index = rt.state.groups.items.findIndex(item => item.id === id)
|
|
496
|
+
if (index === -1) return undefined
|
|
497
|
+
rt.state.groups.items[index] = { ...rt.state.groups.items[index], ...patch }
|
|
498
|
+
rt.touch()
|
|
499
|
+
return rt.state.groups.items[index]
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// ledger.js —— 台账持久化(自研实现)。
|
|
2
|
+
// 单个 JSON 台账,保留角色、群组、任务及新增的任意执行状态。
|
|
3
|
+
import * as fs from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
const EMPTY = {
|
|
6
|
+
roles: { items: [], seq: 0 },
|
|
7
|
+
groups: { items: [], seq: 0 },
|
|
8
|
+
tasks: { items: [], seq: 0 },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function install(rt) {
|
|
12
|
+
const { home, statePath } = rt
|
|
13
|
+
const io = { ...fs, ...rt.config?.ledgerIO }
|
|
14
|
+
|
|
15
|
+
rt.state = structuredClone(EMPTY)
|
|
16
|
+
rt.dirty = false
|
|
17
|
+
let version = 0
|
|
18
|
+
let savedVersion = 0
|
|
19
|
+
|
|
20
|
+
async function ensureHome() {
|
|
21
|
+
await io.mkdir(home, { recursive: true })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 一个共享的初始读取;失败也保留,禁止随后保存覆盖无法读取的台账。
|
|
25
|
+
let loadPromise
|
|
26
|
+
rt.loadAll = () => {
|
|
27
|
+
if (!loadPromise) loadPromise = (async () => {
|
|
28
|
+
await ensureHome()
|
|
29
|
+
let raw
|
|
30
|
+
try {
|
|
31
|
+
raw = await io.readFile(statePath, 'utf8')
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error?.code !== 'ENOENT') throw error
|
|
34
|
+
rt.state = structuredClone(EMPTY)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const state = JSON.parse(raw)
|
|
38
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
39
|
+
throw new TypeError('Invalid ledger: expected an object')
|
|
40
|
+
}
|
|
41
|
+
for (const kind of Object.keys(EMPTY)) {
|
|
42
|
+
if (!Object.hasOwn(state, kind)) continue
|
|
43
|
+
const collection = state[kind]
|
|
44
|
+
if (!collection || typeof collection !== 'object' || Array.isArray(collection)
|
|
45
|
+
|| !Array.isArray(collection.items)
|
|
46
|
+
|| (collection.seq !== undefined && (!Number.isSafeInteger(collection.seq) || collection.seq < 0))) {
|
|
47
|
+
throw new TypeError(`Invalid ledger collection: ${kind}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
rt.state = { ...structuredClone(EMPTY), ...state }
|
|
51
|
+
})()
|
|
52
|
+
return loadPromise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 序列化发生在任何写入等待之前;写完只确认对应版本,不能清除后续修改。
|
|
56
|
+
let writeSeq = 0
|
|
57
|
+
async function atomicWrite(serialized) {
|
|
58
|
+
const tmp = `${statePath}.${process.pid}.${++writeSeq}.tmp`
|
|
59
|
+
await io.writeFile(tmp, serialized, 'utf8')
|
|
60
|
+
await io.rename(tmp, statePath)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let drainPromise
|
|
64
|
+
function drain() {
|
|
65
|
+
if (!drainPromise) drainPromise = (async () => {
|
|
66
|
+
try {
|
|
67
|
+
await rt.loadAll()
|
|
68
|
+
while (savedVersion < version) {
|
|
69
|
+
const writingVersion = version
|
|
70
|
+
const serialized = JSON.stringify(rt.state, null, 2)
|
|
71
|
+
if (serialized === undefined) throw new TypeError('Invalid ledger state')
|
|
72
|
+
await ensureHome()
|
|
73
|
+
await atomicWrite(serialized)
|
|
74
|
+
savedVersion = writingVersion
|
|
75
|
+
rt.dirty = savedVersion < version
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
rt.dirty = true
|
|
79
|
+
throw error
|
|
80
|
+
} finally {
|
|
81
|
+
drainPromise = undefined
|
|
82
|
+
}
|
|
83
|
+
})()
|
|
84
|
+
return drainPromise
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
rt.saveAll = async () => {
|
|
88
|
+
await rt.loadAll()
|
|
89
|
+
// 显式保存也涵盖未经过 touch 的直接状态修改。
|
|
90
|
+
version++
|
|
91
|
+
rt.dirty = true
|
|
92
|
+
do {
|
|
93
|
+
await drain()
|
|
94
|
+
} while (savedVersion < version)
|
|
95
|
+
}
|
|
96
|
+
rt.commit = rt.saveAll
|
|
97
|
+
|
|
98
|
+
// 每次修改都递增版本,微任务仅合并启动;后台失败有处理器且保留 dirty。
|
|
99
|
+
let touchQueued = false
|
|
100
|
+
rt.touch = () => {
|
|
101
|
+
version++
|
|
102
|
+
rt.dirty = true
|
|
103
|
+
if (touchQueued) return
|
|
104
|
+
touchQueued = true
|
|
105
|
+
queueMicrotask(() => {
|
|
106
|
+
touchQueued = false
|
|
107
|
+
if (!rt.dirty) return
|
|
108
|
+
void drain().catch(error => console.warn('[dsh-workbench] save failed:', error))
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 统一 nextId 生成。
|
|
113
|
+
rt.nextId = (kind) => {
|
|
114
|
+
const key = `${kind}Seq`
|
|
115
|
+
rt.state[key] = (rt.state[key] ?? 0) + 1
|
|
116
|
+
return `${kind}_${rt.state[key]}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- 角色 ----
|
|
120
|
+
rt.listRoles = () => [...rt.state.roles.items]
|
|
121
|
+
rt.getRole = (id) => rt.state.roles.items.find(item => item.id === id)
|
|
122
|
+
rt.addRole = (role) => {
|
|
123
|
+
rt.state.roles.items.push(role)
|
|
124
|
+
rt.touch()
|
|
125
|
+
return role
|
|
126
|
+
}
|
|
127
|
+
rt.updateRole = (id, patch) => {
|
|
128
|
+
const index = rt.state.roles.items.findIndex(item => item.id === id)
|
|
129
|
+
if (index === -1) return undefined
|
|
130
|
+
rt.state.roles.items[index] = { ...rt.state.roles.items[index], ...patch }
|
|
131
|
+
rt.touch()
|
|
132
|
+
return rt.state.roles.items[index]
|
|
133
|
+
}
|
|
134
|
+
rt.removeRole = (id) => {
|
|
135
|
+
const before = rt.state.roles.items.length
|
|
136
|
+
rt.state.roles.items = rt.state.roles.items.filter(item => item.id !== id)
|
|
137
|
+
if (rt.state.roles.items.length !== before) rt.touch()
|
|
138
|
+
return rt.state.roles.items.length !== before
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- 群组 ----
|
|
142
|
+
rt.listGroups = () => [...rt.state.groups.items]
|
|
143
|
+
// 按 ID 查找;未命中则按群名回退(防止 AI 成员传群名而非 ID)。
|
|
144
|
+
rt.getGroup = (idOrName) => rt.state.groups.items.find(item => item.id === idOrName)
|
|
145
|
+
?? rt.state.groups.items.find(item => item.name === idOrName)
|
|
146
|
+
rt.addGroup = (group) => {
|
|
147
|
+
rt.state.groups.items.push(group)
|
|
148
|
+
rt.touch()
|
|
149
|
+
return group
|
|
150
|
+
}
|
|
151
|
+
rt.updateGroup = (id, patch) => {
|
|
152
|
+
const index = rt.state.groups.items.findIndex(item => item.id === id)
|
|
153
|
+
if (index === -1) return undefined
|
|
154
|
+
rt.state.groups.items[index] = { ...rt.state.groups.items[index], ...patch }
|
|
155
|
+
rt.touch()
|
|
156
|
+
return rt.state.groups.items[index]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- 任务 ----
|
|
160
|
+
rt.listTasks = () => [...rt.state.tasks.items]
|
|
161
|
+
rt.getTask = (id) => rt.state.tasks.items.find(item => item.id === id)
|
|
162
|
+
rt.addTask = (task) => {
|
|
163
|
+
rt.state.tasks.items.push(task)
|
|
164
|
+
rt.touch()
|
|
165
|
+
return task
|
|
166
|
+
}
|
|
167
|
+
rt.updateTask = (id, patch) => {
|
|
168
|
+
const index = rt.state.tasks.items.findIndex(item => item.id === id)
|
|
169
|
+
if (index === -1) return undefined
|
|
170
|
+
rt.state.tasks.items[index] = { ...rt.state.tasks.items[index], ...patch }
|
|
171
|
+
rt.touch()
|
|
172
|
+
return rt.state.tasks.items[index]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 台账目录路径(供工具/命令展示)。
|
|
176
|
+
rt.ledgerPath = () => statePath
|
|
177
|
+
}
|