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,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
|
+
}
|