sharedComputers.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import Router from '@koa/router'
  2. import {randomUUID} from 'node:crypto'
  3. import {z} from 'zod'
  4. import type {WorkspaceAgent} from '../shared/workspace.js'
  5. import type {WorkspaceStore} from './workspaceStore.js'
  6. import {parse} from './workspaceStore.js'
  7. import {HttpError} from './errors.js'
  8. import type {WorkspaceNodes} from './workspaceGateway.js'
  9. import type {RunnerHub} from './runnerHub.js'
  10. import type {LocalAuthStore} from './localAuth.js'
  11. export interface SharedComputer {managedCompose?:boolean;managedLocalVm?:boolean;id:string;name:string;memberIds:string[];nodeId:string;profile:string;runnerId:string;archived:boolean;createdAt:number}
  12. export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{id?:string;nodeId:string;profile:string;computerEnvironmentId?:string}){
  13. if(!agent.computerEnvironmentId)return
  14. const shared=store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
  15. if(shared.archived||!agent.id||!shared.memberIds.includes(agent.id)||shared.nodeId!==agent.nodeId||(!shared.managedCompose&&!shared.managedLocalVm&&shared.profile!=='*'&&shared.profile!==agent.profile))throw new HttpError(403,'当前成员没有这台共享电脑的授权','shared_computer_forbidden')
  16. }
  17. export class SharedComputers {
  18. constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
  19. private idle(owner:string,ids:string[],environmentId?:string){
  20. const busy=new Set(this.store.list<{agentId:string;status:string}>(owner,'turn').filter(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)).map(work=>work.agentId))
  21. if(busy.size){
  22. const names=[...busy].slice(0,5).map(id=>`「${this.store.get<WorkspaceAgent>(owner,'agent',id)?.name??'未知成员'}」`).join('、')
  23. throw new HttpError(409,`请先停止${names}${busy.size>5?'等成员':''}的当前任务,再修改电脑共享`,'shared_computer_busy')
  24. }
  25. const environments=new Set(ids.map(id=>this.store.get<WorkspaceAgent>(owner,'agent',id)?.computerEnvironmentId??id))
  26. if(environmentId)environments.add(environmentId)
  27. if(this.store.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(ids.includes(grant.agentId)||(grant.environmentId&&environments.has(grant.environmentId)))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
  28. }
  29. private localVmAgents(owner:string){return this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local'&&!a.archived&&!a.remoteAgentId&&!a.temporaryGoalId)}
  30. /** A runner re-enrollment must not fork a second shared desktop: fold every
  31. * stray managed group into one canonical group owned by the current runner,
  32. * keeping the member-rich group so shared files and browser data survive. */
  33. reconcileLocalVmGroups(owner:string,runnerId:string):SharedComputer|undefined{
  34. const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&!g.archived&&g.managedLocalVm)
  35. const canonical=groups.find(g=>g.runnerId===runnerId)??[...groups].sort((a,b)=>b.memberIds.length-a.memberIds.length)[0]
  36. if(!canonical)return undefined
  37. canonical.runnerId=runnerId
  38. for(const stray of groups){
  39. if(stray===canonical)continue
  40. for(const id of stray.memberIds){
  41. const member=this.store.get<WorkspaceAgent>(owner,'agent',id)
  42. if(member&&member.computerEnvironmentId===stray.id){
  43. member.computerEnvironmentId=canonical.id;member.computerEnvironmentName=canonical.name;member.revision++;member.updatedAt=Date.now()
  44. this.store.put(owner,'agent',id,member);this.store.event(owner,'agent.changed',member)
  45. }
  46. if(!canonical.memberIds.includes(id))canonical.memberIds.push(id)
  47. }
  48. stray.archived=true;this.store.put(owner,'shared-computer',stray.id,stray)
  49. }
  50. this.store.put(owner,'shared-computer',canonical.id,canonical)
  51. return canonical
  52. }
  53. assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.localVmAgents(owner).map(a=>a.id))}
  54. bindCompose(owner:string,agent:WorkspaceAgent,desktop:{id:string;name:string},runnerId:string,persist=true){
  55. this.idle(owner,[agent.id])
  56. const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',desktop.id)
  57. if(claim&&(claim.owner!==owner||claim.runnerId!==runnerId))throw new HttpError(403,'这台共享桌面已分配给其他账号或执行节点','compose_desktop_forbidden')
  58. if(agent.computerEnvironmentId&&agent.computerEnvironmentId!==desktop.id)this.detachLocalVm(owner,agent)
  59. const group=this.store.get<SharedComputer>(owner,'shared-computer',desktop.id)??{id:desktop.id,name:desktop.name,nodeId:'local',profile:'*',runnerId,memberIds:[],archived:false,createdAt:Date.now(),managedCompose:true}
  60. group.archived=false;if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
  61. this.store.put('_system','compose-desktop-owner',desktop.id,{owner,runnerId})
  62. this.store.put(owner,'shared-computer',group.id,group)
  63. agent.execution='computer';agent.computer='vm';agent.computerEnvironmentId=desktop.id;agent.computerEnvironmentName=desktop.name
  64. if(persist){agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
  65. }
  66. attachLocalVm(owner:string,agent:WorkspaceAgent,runnerId:string){
  67. if(agent.nodeId!=='local'||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId)return
  68. this.nodes.requireSource(owner,agent)
  69. const group=this.reconcileLocalVmGroups(owner,runnerId)
  70. ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
  71. if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
  72. this.store.put(owner,'shared-computer',group.id,group)
  73. agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name
  74. }
  75. detachLocalVm(owner:string,agent:WorkspaceAgent){
  76. if(!agent.computerEnvironmentId)return
  77. const group=this.store.require<SharedComputer>(owner,'shared-computer',agent.computerEnvironmentId)
  78. group.memberIds=group.memberIds.filter(id=>id!==agent.id);group.archived=group.memberIds.length===0
  79. this.store.put(owner,'shared-computer',group.id,group)
  80. delete agent.computerEnvironmentId;delete agent.computerEnvironmentName
  81. this.store.put(owner,'agent',agent.id,agent)
  82. }
  83. /** Returns the desktop environments this switch orphaned, so the caller can
  84. * stop them on the runner; a leftover per-bot desktop would otherwise keep
  85. * consuming the concurrency quota and block the shared desktop's members. */
  86. setLocalVmMode(owner:string,mode:'shared'|'per-bot',runnerId:string):{retiredEnvironmentIds:string[]}{
  87. this.assertLocalVmIdle(owner)
  88. return this.store.atomic(()=>{
  89. const agents=this.localVmAgents(owner)
  90. const previous=new Map(agents.map(a=>[a.id,{id:a.computerEnvironmentId,name:a.computerEnvironmentName}]))
  91. const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&(g.managedLocalVm||g.runnerId===runnerId))
  92. for(const group of groups){group.archived=true;this.store.put(owner,'shared-computer',group.id,group)}
  93. for(const agent of agents){
  94. if(agent.computerEnvironmentId&&groups.some(g=>g.id===agent.computerEnvironmentId)){delete agent.computerEnvironmentId;delete agent.computerEnvironmentName}
  95. }
  96. if(mode==='shared'){
  97. const members=agents.filter(a=>!a.computerEnvironmentId)
  98. if(members.length){
  99. const group=[...groups.filter(g=>g.managedLocalVm)].sort((a,b)=>b.memberIds.length-a.memberIds.length)[0]
  100. ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
  101. group.profile='*';group.archived=false;group.managedLocalVm=true;group.runnerId=runnerId;group.memberIds=members.map(a=>a.id);this.store.put(owner,'shared-computer',group.id,group)
  102. for(const agent of members){agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name}
  103. }
  104. }
  105. const retiredEnvironmentIds:string[]=[]
  106. for(const agent of agents){
  107. const before=previous.get(agent.id)!
  108. if(before.id===agent.computerEnvironmentId&&before.name===agent.computerEnvironmentName)continue
  109. agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)
  110. const retired=before.id??agent.id
  111. if(retired!==(agent.computerEnvironmentId??agent.id))retiredEnvironmentIds.push(retired)
  112. }
  113. return {retiredEnvironmentIds}
  114. })
  115. }
  116. create(owner:string,input:unknown){
  117. const body=parse(z.object({requestId:z.string().uuid(),name:z.string().trim().min(1).max(100),memberIds:z.array(z.string().uuid()).min(2).max(8),trusted:z.literal(true)}).strict(),input)
  118. return this.store.command(owner,body.requestId,{operation:'computer.share',...body},()=>this.store.atomic(()=>{
  119. if(new Set(body.memberIds).size!==body.memberIds.length)throw new HttpError(400,'成员不能重复','shared_computer_members')
  120. if(this.store.list<SharedComputer>(owner,'shared-computer').filter(value=>!value.archived).length>=16)throw new HttpError(409,'共享电脑数量已达上限','shared_computer_limit')
  121. const agents=body.memberIds.map(id=>this.store.require<WorkspaceAgent>(owner,'agent',id)),first=agents[0]!
  122. for(const agent of agents){
  123. this.nodes.requireSource(owner,agent)
  124. if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId||agent.nodeId!==first.nodeId)throw new HttpError(400,'请选择同一来源的持久隔离成员,且成员尚未加入其他共享电脑','shared_computer_members')
  125. }
  126. this.idle(owner,body.memberIds)
  127. const runnerId=this.hub.sharedComputerRunner(owner,first).id
  128. const shared:SharedComputer={id:randomUUID(),name:body.name,memberIds:body.memberIds,nodeId:first.nodeId,profile:'*',runnerId,archived:false,createdAt:Date.now()}
  129. this.store.put(owner,'shared-computer',shared.id,shared)
  130. for(const agent of agents){agent.computerEnvironmentId=shared.id;agent.computerEnvironmentName=shared.name;agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
  131. return shared
  132. }))
  133. }
  134. remove(owner:string,id:string){
  135. return this.store.atomic(()=>{
  136. const shared=this.store.require<SharedComputer>(owner,'shared-computer',id)
  137. if(shared.archived)return
  138. this.idle(owner,shared.memberIds,id)
  139. for(const agent of this.store.list<WorkspaceAgent>(owner,'agent').filter(agent=>agent.computerEnvironmentId===id)){
  140. delete agent.computerEnvironmentId;delete agent.computerEnvironmentName;agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)
  141. }
  142. shared.archived=true;this.store.put(owner,'shared-computer',id,shared)
  143. })
  144. }
  145. router(){
  146. const router=new Router()
  147. router.get('/api/app/computers/shared',ctx=>{const owner=this.auth.require(ctx).id;ctx.body={computers:this.store.list<SharedComputer>(owner,'shared-computer').filter(value=>!value.archived)}})
  148. router.post('/api/app/computers/shared',ctx=>{ctx.body={computer:this.create(this.auth.require(ctx).id,(ctx.request as any).body)};ctx.status=201})
  149. router.delete('/api/app/computers/shared/:id',ctx=>{this.remove(this.auth.require(ctx).id,ctx.params.id);ctx.body={ok:true}})
  150. return router
  151. }
  152. }