|
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
|
import { execFile } from 'node:child_process'
|
|
|
import { promisify } from 'node:util'
|
|
|
+import { randomUUID } from 'node:crypto'
|
|
|
import { RunnerAgent } from '../runner/agent.js'
|
|
|
import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
|
|
|
import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
|
|
|
@@ -43,6 +44,18 @@ export class LocalVmService {
|
|
|
return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version
|
|
|
}
|
|
|
private mode(owner:string):LocalVmMode { return this.fixed?'shared':this.store.get<{mode:LocalVmMode}>(owner,'local-vm-preferences','local')?.mode??'per-bot' }
|
|
|
+ /** Heals groups stranded on a superseded runner record; without it those
|
|
|
+ * members keep failing with shared_runner_changed and never rejoin sharing. */
|
|
|
+ private reconcile(owner:string,record:{id:string}|undefined){
|
|
|
+ if(this.fixed||!record||this.mode(owner)!=='shared')return
|
|
|
+ try{this.shared.reconcileLocalVmGroups(owner,record.id)}catch{/* best effort; retried on the next check */}
|
|
|
+ }
|
|
|
+ private async stopDesktops(owner:string,runnerId:string,environmentIds:string[]){
|
|
|
+ for(const environmentId of environmentIds){
|
|
|
+ const id=randomUUID();this.grant(owner,id,runnerId)
|
|
|
+ await this.hub.localVm(owner,runnerId,{op:'instance',id,environmentId,action:'stop'}).catch(()=>{})
|
|
|
+ }
|
|
|
+ }
|
|
|
private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
|
|
|
async start(url:string) {
|
|
|
this.url=url
|
|
|
@@ -97,6 +110,7 @@ export class LocalVmService {
|
|
|
})().finally(()=>{this.starting=undefined})
|
|
|
await this.starting
|
|
|
const record=this.record()!
|
|
|
+ this.reconcile(owner,record)
|
|
|
const deadline=Date.now()+8000
|
|
|
while(!this.hub.summary(record).online&&Date.now()<deadline)await new Promise(r=>setTimeout(r,100))
|
|
|
return record
|
|
|
@@ -113,6 +127,7 @@ export class LocalVmService {
|
|
|
const runner=record?this.hub.summary(record):undefined,connected=runner?.online&&runner.features.includes('compose-desktops-v1')
|
|
|
return {configured:!!connected,executionHost:'runner',runnerName:record?.name,fixedCapacity:true,desktops,daemonUp:true,image:desktops.some(d=>d.ready),mode:'shared',maxInstances:desktops.length,busy:false,...(!connected?{setupRequired:'runner' as const,problem:'Compose 桌面数量已固定。请连接启用 Compose 桌面模式的 Hermes 执行节点。'}:{})}
|
|
|
}
|
|
|
+ this.reconcile(owner,record)
|
|
|
const executionHost=this.config.localVmHost==='runner'||(record&&!this.store.get('_system','local-vm-managed','local'))?'runner':'server'
|
|
|
if(record){
|
|
|
if(this.url&&!this.managed)await this.start(this.url)
|
|
|
@@ -155,8 +170,9 @@ export class LocalVmService {
|
|
|
const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
|
|
|
this.grant(owner,body.requestId,record.id)
|
|
|
await this.hub.localVm(owner,record.id,{op:'policy',id:body.requestId,mode:body.mode,maxInstances:body.maxInstances})
|
|
|
- this.shared.setLocalVmMode(owner,body.mode,record.id)
|
|
|
+ const {retiredEnvironmentIds}=this.shared.setLocalVmMode(owner,body.mode,record.id)
|
|
|
this.store.put(owner,'local-vm-preferences','local',{mode:body.mode})
|
|
|
+ await this.stopDesktops(owner,record.id,retiredEnvironmentIds)
|
|
|
ctx.body=await this.status(owner)
|
|
|
})
|
|
|
router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
|
|
|
@@ -175,7 +191,7 @@ export class LocalVmService {
|
|
|
try { ctx.body={enabled:true,agent,...await this.hub.computer(owner,agent,'detail',{},()=>this.nodes.requireSource(owner,agent)),mode:agent.computerEnvironmentId?'shared':'per-bot',...fixed} }
|
|
|
catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用',...fixed}}
|
|
|
})
|
|
|
- router.put('/api/app/agents/:id/local-vm',ctx=>{
|
|
|
+ router.put('/api/app/agents/:id/local-vm',async ctx=>{
|
|
|
const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean(),desktopId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
|
|
|
const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,agent)
|
|
|
if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该机器人所属的电脑上设置虚拟机','local_vm_remote')
|
|
|
@@ -187,12 +203,14 @@ export class LocalVmService {
|
|
|
if(!record||!this.hub.summary(record).features.includes('compose-desktops-v1'))throw new HttpError(409,'请连接启用 Compose 桌面模式的执行节点','compose_runner_required')
|
|
|
ctx.body=this.store.atomic(()=>{this.shared.bindCompose(owner,agent,desktop,record.id);return {agent:this.store.agentSummary(agent)}});return
|
|
|
}
|
|
|
+ let retiredAfterEnable:string[]=[]
|
|
|
ctx.body=this.store.atomic(()=>{
|
|
|
if(!body.enabled)this.shared.detachLocalVm(owner,agent)
|
|
|
this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile',computer:body.enabled?'vm':'off'})
|
|
|
- if(body.enabled&&!this.fixed&&this.mode(owner)==='shared'){const record=this.record();if(record)this.shared.setLocalVmMode(owner,'shared',record.id)}
|
|
|
+ if(body.enabled&&!this.fixed&&this.mode(owner)==='shared'){const record=this.record();if(record)retiredAfterEnable=this.shared.setLocalVmMode(owner,'shared',record.id).retiredEnvironmentIds}
|
|
|
return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
|
|
|
})
|
|
|
+ if(retiredAfterEnable.length){const record=this.record();if(record)await this.stopDesktops(owner,record.id,retiredAfterEnable)}
|
|
|
})
|
|
|
router.put('/api/app/agents/:id/local-vm/image',async ctx=>{
|
|
|
const owner=this.auth.require(ctx).id;this.assertManaged()
|