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.

@@ -0,0 +1,274 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import { ExecutionEngine } from './execution-engine.js'
4
+ import { MESSAGE_POLICY } from './policy.js'
5
+
6
+ const brief = value => { const text = String(value ?? '').replace(/\s+/g, ' ').trim(); return text.length > 110 ? text.slice(0, 109) + '…' : text }
7
+
8
+ const LIVE = new Set(['queued','retry','starting','running','waiting','blocked','paused','failed'])
9
+ const LABELS = {'approval-wait':'等待权限审批',queued:'排队',retry:'等待重试',starting:'启动中',running:'执行中',waiting:'等待后续',blocked:'阻塞',paused:'暂停',failed:'失败',completed:'完成',cancelled:'停止'}
10
+
11
+ /** Adapter between durable workbench runs and ordinary Harness Sessions. */
12
+ export function install(rt) {
13
+ const {ctx}=rt
14
+ let disposed=false, timer, pumping=false, again=false
15
+ const work = new Set()
16
+ const background = fn => {
17
+ const launch=()=>Promise.resolve().then(fn).catch(error => {
18
+ rt.executionError=String(error?.message ?? error)
19
+ console.warn('[dsh-workbench] execution:', error)
20
+ })
21
+ const agents=ctx.get?.('agents')
22
+ const task = agents?.withoutInitiator ? agents.withoutInitiator(launch) : launch()
23
+ work.add(task)
24
+ task.finally(()=>work.delete(task))
25
+ }
26
+ const state = () => (rt.state.execution ??= {version:1,runs:[],inputs:[]})
27
+ const liveAgent = id => ctx.get?.('agents')?.get(id)
28
+ rt.currentExecution = () => {
29
+ const agent=ctx.get?.('agents')?.currentInitiator()
30
+ return agent ? state().runs.find(r=>r.sessionId===agent.id) : undefined
31
+ }
32
+ const currentRuns = task => {
33
+ const all=state().runs.filter(r=>r.taskId===task.id)
34
+ const generation=Math.max(1,...all.map(r=>r.generation??1))
35
+ return task.assignees.map(roleId=>all.filter(r=>r.generation===generation && r.roleId===roleId).at(-1)).filter(Boolean)
36
+ }
37
+ const gate = run => {
38
+ if(disposed || rt.executionError) return false
39
+ const task=rt.getTask(run.taskId),group=rt.getGroup(run.groupId),role=rt.getRole(run.roleId)
40
+ if(rt.movingGroups?.has(run.groupId)) return false
41
+ if(!task || task.status==='已完成' || group?.status!=='active' || group.pendingDecision || role?.status!=='active' || !group.members.includes(run.roleId) || !task.assignees.includes(run.roleId)) return false
42
+ if((task.dependencies??[]).some(id=>rt.getTask(id)?.status!=='已完成')) return false
43
+ // Different tasks sharing one project directory must not concurrently edit it.
44
+ for(const other of state().runs) {
45
+ if(other.id===run.id) continue
46
+ const physicallyRunning=other.sessionId && liveAgent(other.sessionId)?.status==='running'
47
+ const active=['starting','running','approval-wait'].includes(other.status) || physicallyRunning
48
+ if(active && (other.roleId===run.roleId || (other.groupId===run.groupId && other.taskId!==run.taskId))) return false
49
+ }
50
+ const physical=state().runs.filter(r=>r.id!==run.id && r.sessionId && liveAgent(r.sessionId)?.status==='running').length
51
+ return physical<engine.maxConcurrent
52
+ }
53
+ const persist=async()=>{ rt.touch();await rt.saveAll() }
54
+ const transport={
55
+ async ensureSession(run) {
56
+ const group=rt.getGroup(run.groupId),role=rt.getRole(run.roleId)
57
+ if(!group || group.status!=='active' || !role) throw new Error('任务所属群组或成员不可用')
58
+ // Explicit stable identity closes the crash window between create and saving its result.
59
+ const sessionId=`session-dwb-${run.id}`
60
+ try { await ctx.sessionController.inspect(sessionId,AbortSignal.timeout(5000)) }
61
+ catch(error) {
62
+ if(error?.code!=='session/not-found' && !/not.found|不存在|未找到/i.test(String(error?.message))) throw error
63
+ await ctx.sessionController.create({sessionId,workspaceId:await rt.ensureGroupWorkspace(group)})
64
+ }
65
+ const agent=await rt.resolveAgent(sessionId)
66
+ if(role.model) await rt.applySessionModel(sessionId,role.model)
67
+ await ctx.sessionTitle.rename(agent.session,`${role.name} · ${run.taskId} · ${group.name}`)
68
+ await ctx.sessions.flush(agent.session)
69
+ return sessionId
70
+ },
71
+ async deliver(run,input) {
72
+ const group=rt.getGroup(run.groupId),task=rt.getTask(run.taskId),role=rt.getRole(run.roleId)
73
+ if(!gate(run)) throw new Error('当前任务已暂停或不满足执行条件')
74
+ const agent=await rt.resolveAgent(run.sessionId)
75
+ // Check durable admission on every send retry, not just at process startup.
76
+ const events=agent.session.snapshotEvents()
77
+ const inbox=ctx.sessionProjections.stateOf(agent.session,'inbox')
78
+ if(!inbox) throw new Error('当前宿主缺少 durable inbox 投影')
79
+ const admitted=Object.values(inbox).flat().some(m=>m.source?.executionInputId===input.id) || events.some(e=>e.type==='user/message' && e.data?.source?.executionInputId===input.id)
80
+ if(admitted) { await ctx.sessions.flush(agent.session);return }
81
+ const seed=[
82
+ `你是工作台成员「${role.name}」,roleId=${role.id},groupId=${group.id}。职责:${role.instructions||'按任务要求执行'}。`,
83
+ `当前独立执行:taskId=${task.id},runId=${run.id},工作目录=${group.dir}。任务:${task.title}。`,
84
+ group.notice?`群公告:${group.notice}`:'',
85
+ '系统已经安排执行,请直接推进本任务;不要等待额外 @。仅处理本任务范围,先检查已有成果,避免恢复时重复操作。',
86
+ MESSAGE_POLICY,
87
+ '需行动的群消息用 dwb_post_group_message,带 taskId;读取本任务及输入用 dwb_task_execution。',
88
+ '本轮结束前必须调用 dwb_report_execution:completed 要附简要结果和 evidence(成果路径、测试结果或可核验引用);遇到依赖或用户决策则报 blocked/waiting。不要用 dwb_update_task 将整个多人任务标为完成。',
89
+ '需要用户拍板用 dwb_ask_user,调用后结束本轮;恢复、重试和消息输入不扩大用户授权范围。',
90
+ ].filter(Boolean).join('\n')
91
+ const digest=input.kind==='assignment'?await rt.memoryDigest?.(run.roleId):''
92
+ const body=input.text.slice(0,6000)+(input.text.length>6000?`\n[输入已截断,用 dwb_task_execution 传 taskId=${task.id}、inputId=${input.id}、inputOffset=6000 续读]`:'')
93
+ const context=['assignment','recovery'].includes(input.kind)?seed:`taskId=${task.id},runId=${run.id},groupId=${group.id}。本轮结束前用 dwb_report_execution 汇报结果;只处理当前任务,不另发重复回执或开工消息,过程仅留任务记录,需行动时才发群消息。`
94
+ const message={...createUserMessage({content:[{type:'text',text:`${context}\n${digest||''}\n\n【${input.kind}|输入 ${input.id}】\n${body}`}],source:{kind:'dsh-workbench',form:'execution',groupId:group.id,taskId:task.id,runId:run.id,executionInputId:input.id}}),id:`dwb-input-${input.id}`}
95
+ agent.followup(message)
96
+ await ctx.sessions.flush(agent.session)
97
+ },
98
+ async cancel(run) {
99
+ const agent=liveAgent(run.sessionId)
100
+ if(agent) { agent.cancel({kind:'user'});await ctx.sessions.flush(agent.session) }
101
+ },
102
+ }
103
+ const engine=new ExecutionEngine({getState:state,persist,transport,canRun:gate,
104
+ maxConcurrent:rt.config.maxConcurrent??3,maxAttempts:rt.config.maxAttempts??3,
105
+ retryBaseMs:rt.config.retryBaseMs??2000,staleMs:rt.config.staleMs??15*60*1000})
106
+ rt.drainExecution = async () => {
107
+ await rt.executionReady
108
+ while(work.size) await Promise.all([...work])
109
+ await engine.tail
110
+ }
111
+ rt.executionEngine=engine
112
+ ctx.tools.guard?.(exec=>{
113
+ const run=(rt.state.execution?.runs??[]).find(r=>r.sessionId===exec.agent?.id)
114
+ if(!run) return
115
+ if(!['starting','running','waiting','approval-wait'].includes(run.status)) return '该执行已停止、暂停或结束;请由管理员检查执行状态。'
116
+ if(['dwb_create_task','dwb_manage_execution','dwb_create_role','dwb_delete_role','dwb_update_role','dwb_set_role_status','dwb_create_group','dwb_delete_group','dwb_rename_group','dwb_command_new_project'].includes(exec.name)) return '当前会话负责独立任务执行;任务调度和团队管理由管理员处理。'
117
+ })
118
+ rt.executionRuns=taskId=>engine.list(taskId)
119
+ const sync = async (deliver = true) => {
120
+ let changed=false
121
+ for(const task of rt.listTasks()) {
122
+ const runs=currentRuns(task)
123
+ if(!runs.length) continue
124
+ const completed=runs.every(r=>r.status==='completed')
125
+ const active=runs.some(r=>['starting','running','approval-wait'].includes(r.status))
126
+ const status=completed?'已完成':active?'进行中':task.status==='已完成'?'已完成':'待办'
127
+ const detail=runs.map(r=>`${rt.getRole(r.roleId)?.name??r.roleId}:${LABELS[r.status]??r.status}${r.attention?'(需关注)':''}`).join(';')
128
+ if(task.status!==status || task.executionSummary!==detail) {
129
+ rt.updateTaskRecord(task.id,{status,executionSummary:detail,completedAt:completed?(task.completedAt??Date.now()):task.completedAt,updatedAt:Date.now()});changed=true
130
+ }
131
+ }
132
+ const notices = (state().notices ??= [])
133
+ for (const run of state().runs) {
134
+ const needsAttention = ['failed','blocked'].includes(run.status) || run.attention
135
+ const fingerprint = needsAttention ? `${run.status}:${run.attention??''}:${run.summary}` : null
136
+ if (run.alertFingerprint === fingerprint) continue
137
+ run.alertFingerprint = fingerprint; changed = true
138
+ if (fingerprint) notices.push({id:randomUUID(),groupId:run.groupId,taskId:run.taskId,roleId:run.roleId,status:'pending',text:`⚠️ 执行需要关注(${run.taskId} · ${LABELS[run.status]}):${brief(run.summary || run.error || '请查看执行状态')}。详情见任务记录,排除阻塞后由管理员重试。`})
139
+ }
140
+ if(changed) await rt.saveAll()
141
+ await rt.syncDelivery?.({deliver})
142
+ await rt.syncWorkItems?.()
143
+ if (!deliver) return
144
+ for (const notice of notices.filter(n=>n.status==='pending')) {
145
+ if (rt.getGroup(notice.groupId)?.status !== 'active') {notice.status='discarded';await persist();continue}
146
+ const send=()=>rt.postGroupMessage({groupId:notice.groupId,taskId:notice.taskId,fromRoleId:notice.roleId,noWake:true,messageId:notice.id,text:notice.text})
147
+ const agents=ctx.get?.('agents')
148
+ try {
149
+ if (agents?.withoutInitiator) await agents.withoutInitiator(send)
150
+ else await send()
151
+ notice.status='sent';await persist()
152
+ } catch(error) {notice.error=String(error?.message??error);await persist()}
153
+ }
154
+ }
155
+ rt.kickExecution=()=> {
156
+ if(disposed) return
157
+ if(pumping) {again=true;return}
158
+ background(async()=>{
159
+ if(pumping) {again=true;return}
160
+ pumping=true
161
+ try { await rt.executionReady; do {again=false;await engine.tick();await sync()} while(again&&!disposed) }
162
+ finally {pumping=false}
163
+ })
164
+ }
165
+ rt.taskCreated=async task=>{ await engine.enqueue(task) }
166
+ rt.taskChanged=async task=>{ await engine.reconcileTask(task);await sync() }
167
+ rt.validateExecutionTaskUpdate=async(task,next)=>{
168
+ const caller=rt.currentExecution()
169
+ if(caller && (caller.taskId!==task.id || next.status==='已完成' || Object.keys(next).some(k=>k!=='status'))) throw new Error('任务执行成员请用 dwb_report_execution 汇报自己的结果;任务范围和指派由管理员管理')
170
+ }
171
+ rt.reportExecution=async({runId,status,summary,evidence=[]})=>{
172
+ await rt.executionReady
173
+ const caller=rt.currentExecution()
174
+ if(!caller || caller.id!==runId) throw new Error('只能由该执行会话汇报自己的 runId')
175
+ if(!LIVE.has(caller.status)) throw new Error('该执行已结束,旧会话不能提交结果')
176
+ const previous = engine.list(caller.taskId).find(r=>r.id===runId)
177
+ const duplicate = previous?.status === status && previous?.summary === summary
178
+ const ok=await engine.report(runId,{status,summary,evidence})
179
+ if(!ok) throw new Error('执行已暂停或失效')
180
+ await sync()
181
+ // blocked/failed are announced once by sync; running stays in the task record.
182
+ if (!duplicate && ['completed','waiting'].includes(status)) await rt.postGroupMessage({groupId:caller.groupId,taskId:caller.taskId,fromRoleId:caller.roleId,noWake:true,
183
+ text:`【${caller.taskId}|${LABELS[status]}】${brief(summary)}\n详情与依据:任务记录(dwb_task_execution)`})
184
+ rt.kickExecution();return engine.list(caller.taskId).find(r=>r.id===runId)
185
+ }
186
+ rt.retryExecution=async taskId=>{
187
+ await rt.executionReady
188
+ const task=rt.getTask(taskId)
189
+ if(!task || task.status==='已完成') throw new Error('任务不存在或已经完成')
190
+ if(rt.getGroup(task.groupId)?.pendingDecision) throw new Error('请先回复待定决策')
191
+ const runs=await engine.retryTask(taskId);await sync();rt.kickExecution();return runs
192
+ }
193
+ rt.stopTaskExecution=async taskId=>{await rt.executionReady;const runs=await engine.cancelTask(taskId);await sync();return runs}
194
+ rt.pauseRoleExecution=async(groupId,roleId)=>{
195
+ await rt.executionReady
196
+ await engine.pauseRole(groupId,roleId)
197
+ await sync()
198
+ }
199
+ rt.cancelGroupExecution=async groupId=>{
200
+ await rt.executionReady
201
+ const runs=state().runs.filter(r=>r.groupId===groupId)
202
+ for(const taskId of new Set(runs.map(r=>r.taskId))) await engine.cancelTask(taskId)
203
+ return runs.map(r=>r.sessionId??`session-dwb-${r.id}`)
204
+ }
205
+ rt.routeTaskMessage=async(group,line,taskId,roleIds)=>{
206
+ await rt.executionReady
207
+ const task=rt.getTask(taskId)
208
+ if(!task || task.groupId!==group.id) throw new Error('消息关联任务必须属于当前群组')
209
+ const attachments=(line.attachments??[]).map(f=>`附件:${f.relPath}`).join('\n')
210
+ const inputs=await engine.input(task.id,`来自 ${line.fromName}:${line.text}\n${attachments}`,{eventId:line.id,roleIds})
211
+ rt.kickExecution()
212
+ return [...new Set(inputs.map(input=>state().runs.find(r=>r.id===input.runId)?.roleId).filter(Boolean))]
213
+ }
214
+ rt.resolveDecision=async(groupId,answer,seq)=>{
215
+ const group=rt.getGroup(groupId),pending=group?.pendingDecision
216
+ if(!pending) throw new Error('当前没有待回复决策')
217
+ if(seq!==undefined && Number(seq)!==pending.seq) throw new Error('决策已变化,请回复当前决策')
218
+ if(!String(answer??'').trim()) throw new Error('决策回复不能为空')
219
+ const record={seq:pending.seq,answer:String(answer),at:Date.now(),taskId:pending.taskId}
220
+ await engine.resolveDecision(pending.taskId,`用户对决策 ${pending.seq} 的回复:${answer}`,{
221
+ eventId:`decision:${group.id}:${pending.seq}`,runId:pending.runId,
222
+ commit:()=>{group.decisionAnswers=[...(group.decisionAnswers??[]),record].slice(-100);group.pendingDecision=null},
223
+ })
224
+ rt.kickExecution()
225
+ return pending
226
+ }
227
+ const normalize = event => event.type==='turn/end'?{...event,status:event.data?.reason?.kind,cause:event.data?.reason?.reason?.kind,error:event.data?.reason?.error}:event
228
+ ctx.on('session/event',(session,event)=>{
229
+ if(!['user/message','agent/inbox/spliced','tool/call','tool/result','turn/end','approval/asked','approval/decided'].includes(event.type)) return
230
+ if(disposed || !(rt.state.execution?.runs??[]).some(r=>r.sessionId===session.id)) return
231
+ background(async()=>{await rt.executionReady;await engine.event(session.id,normalize(event));await sync();rt.kickExecution()})
232
+ })
233
+ rt.startExecution=()=>{
234
+ rt.executionReady=(async()=>{
235
+ await rt.loadAll();await rt.migrateRoleInstructions?.()
236
+ const first=!rt.state.execution
237
+ state()
238
+ if(first) rt.state.execution.migratedAt=Date.now()
239
+ for(const task of rt.listTasks().filter(t=>t.status!=='已完成')) {
240
+ const runs=engine.list(task.id)
241
+ if(!runs.length) await engine.enqueue(task,{legacy:!task.schedulerVersion})
242
+ else if(currentRuns(task).some(r=>(r.taskRevision??1)!==(task.revision??1))) await engine.reconcileTask(task)
243
+ }
244
+ await persist()
245
+ const observations=new Map()
246
+ for(const run of state().runs.filter(r=>r.sessionId && !['completed','cancelled'].includes(r.status))) {
247
+ try {
248
+ const inspection=await ctx.sessionController.inspect(run.sessionId,AbortSignal.timeout(5000))
249
+ observations.set(run.sessionId,{events:inspection.events.map(normalize),running:liveAgent(run.sessionId)?.status==='running'})
250
+ } catch(error) {
251
+ // An unreadable existing session is a recovery problem, never permission to redo it.
252
+ run.status='blocked';run.error=`恢复读取失败:${String(error?.message??error)}`
253
+ }
254
+ }
255
+ await engine.recover(observations);await sync(false)
256
+ })()
257
+ // Startup failures keep dispatch gated and stay visible through execution.status.
258
+ rt.executionReady.catch(error=>{rt.executionError=String(error?.message??error)})
259
+ timer=setInterval(()=>rt.kickExecution(),Math.max(250,rt.config.tickMs??2000));timer.unref?.()
260
+ rt.kickExecution()
261
+ return rt.executionReady
262
+ }
263
+ ctx.effect(()=>async()=>{
264
+ disposed=true;clearInterval(timer)
265
+ await rt.executionReady?.catch(()=>{})
266
+ while(work.size) await Promise.all([...work])
267
+ await engine.tail
268
+ for (const run of rt.state.execution?.runs ?? []) {
269
+ const agent=run.sessionId ? liveAgent(run.sessionId) : null
270
+ if(agent?.status==='running') {agent.cancel({kind:'disposed'},{keepInbox:true});await ctx.sessions.flush(agent.session)}
271
+ }
272
+ await rt.saveAll()
273
+ },'dsh-workbench: execution lifetime')
274
+ }
@@ -0,0 +1,89 @@
1
+ // files.js —— 群文件(附件)管理。
2
+ // 群聊输入框 + 号上传的文件落入群工作目录的「附件/」子目录,成为群资产:
3
+ // - 成员会话 cwd 即群目录,可直接用 read/glob 访问(图片用 read_image);
4
+ // - 消息流以文件卡片呈现(client 用 FileTypeIcon 渲染);
5
+ // - 文件是普通磁盘文件,用户也能在文件系统中直接查看。
6
+ // 安全:文件名清洗去路径;删除只允许作用于附件目录内的文件(路径解析后前缀校验)。
7
+ import { mkdir, writeFile, rm, stat } from 'node:fs/promises'
8
+ import { join, basename, extname, resolve, sep, relative } from 'node:path'
9
+ import { randomUUID } from 'node:crypto'
10
+
11
+ export const ATTACH_DIR = '附件'
12
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024 // 单文件 10MB(RPC 为 base64 JSON,不放大文件)
13
+
14
+ // 文件名清洗:取 basename、去控制字符、去前导点、限长 120 字符。
15
+ function sanitizeName(raw) {
16
+ const base = basename(String(raw ?? ''))
17
+ .replace(/[\u0000-\u001f\u007f]/g, '')
18
+ .replace(/^\.+/, '')
19
+ .trim()
20
+ .slice(0, 120)
21
+ return base || `文件-${randomUUID().slice(0, 8)}`
22
+ }
23
+
24
+ // 同名冲突:追加「 (2)」「 (3)」…,仍冲突则 uuid 后缀兜底。
25
+ async function findFreeName(dir, name) {
26
+ const ext = extname(name)
27
+ const stem = ext ? name.slice(0, -ext.length) : name
28
+ let candidate = name
29
+ for (let i = 2; i < 100; i++) {
30
+ try {
31
+ await stat(join(dir, candidate))
32
+ candidate = `${stem} (${i})${ext}`
33
+ } catch {
34
+ return candidate
35
+ }
36
+ }
37
+ return `${stem}-${randomUUID().slice(0, 8)}${ext}`
38
+ }
39
+
40
+ export function install(rt) {
41
+ const attachDirOf = (group) => join(group.dir, ATTACH_DIR)
42
+
43
+ // 上传一个文件(base64)→ 写入群目录「附件/」,返回文件记录。
44
+ rt.uploadGroupFile = async ({ groupId, name, base64 }) => {
45
+ await rt.loadAll()
46
+ const group = rt.getGroup(groupId)
47
+ if (!group) throw new Error(`群组 ${groupId} 不存在`)
48
+ if (group.status !== 'active') throw new Error(`群组「${group.name}」已归档,不能上传附件`)
49
+ if (!group.dir) throw new Error('该群组没有工作目录,无法保存附件')
50
+
51
+ const data = Buffer.from(String(base64 ?? ''), 'base64')
52
+ if (data.length === 0) throw new Error('文件内容为空或编码无效')
53
+ if (data.length > MAX_FILE_BYTES) {
54
+ throw new Error(`文件超过 ${Math.round(MAX_FILE_BYTES / 1024 / 1024)}MB 上限`)
55
+ }
56
+
57
+ const dir = attachDirOf(group)
58
+ await mkdir(dir, { recursive: true })
59
+ const freeName = await findFreeName(dir, sanitizeName(name))
60
+ const path = join(dir, freeName)
61
+ await writeFile(path, data)
62
+ return {
63
+ name: freeName,
64
+ size: data.length,
65
+ path,
66
+ relPath: `${ATTACH_DIR}/${freeName}`,
67
+ }
68
+ }
69
+
70
+ // 删除一个已上传但未随消息发送的附件。
71
+ // 只允许删「附件/」目录内的文件——路径解析后做前缀校验,拒绝越界。
72
+ rt.removeGroupFile = async ({ groupId, path }) => {
73
+ await rt.loadAll()
74
+ const group = rt.getGroup(groupId)
75
+ if (!group) throw new Error(`群组 ${groupId} 不存在`)
76
+ if (!group.dir) throw new Error('该群组没有工作目录')
77
+ const attachDir = resolve(attachDirOf(group))
78
+ const target = resolve(String(path ?? ''))
79
+ const rel = relative(attachDir, target)
80
+ // rel 为空 = 目标就是附件目录本身;rel 以 .. 开头 = 越界。都拒绝。
81
+ if (!rel || rel.startsWith('..') || rel.includes(`..${sep}`)) {
82
+ throw new Error('只能删除附件目录中的文件')
83
+ }
84
+ const info = await stat(target).catch(() => null)
85
+ if (!info?.isFile()) throw new Error('文件不存在')
86
+ await rm(target)
87
+ return { removed: true, path: target }
88
+ }
89
+ }