Просмотр исходного кода

完善镜像分块重试与读取回执并补齐交付验收

LSamien 1 месяц назад
Родитель
Сommit
866b0e380c

+ 4 - 0
docs/computer-management-ui.md

@@ -32,3 +32,7 @@ Runner 使用 `image-management-v1` 能力协商,镜像选择与任务记录
 Web 流程覆盖常驻入口、未配置提示、应用/回退、导出下载及旧服务提示,并检查窄屏深色截图。手机 UI 流程确认没有管理按钮,分别显示独立/共享屏幕并完成接管交还。已有 iOS 28 项 Workspace 测试通过,Android 编译与原生 UI 流程通过。
 
 真实 Docker + 打包 Runner + 控制面已完成镜像准备、校验、应用、回退,并导出、分块下载/上传及重新导入约 500 MB 的兼容镜像,核对 SHA-256。镜像完成后继续执行原有 Worker、共享电脑和临时助手验收。
+
+本分支最终单元/集成测试为 775 项通过、6 项按环境跳过;浏览器流程 7 项通过,真实 Worker 流程 2 项通过。下载块只保存命令指纹,每次重试重新鉴权读取,避免在回执数据库中重复存储整个归档;上传块在网络中断或服务器暂时错误时最多重试两次。
+
+手机端源码为 iOS 1.3(189),已签名并安装至 iPhone 13 Pro Max;安装后设备离线,启动核验尚未完成。Android 已通过编译和模拟器界面验证。新版客户端连接旧服务时显示升级提示;本次未替换正式运行中的 Web 服务。

+ 4 - 1
src/client/components/workspace/ImageManagementPanel.vue

@@ -9,6 +9,9 @@ const base=()=>`/api/app/admin/runners/${encodeURIComponent(selected.value)}/ima
 const names:Record<ImageOperation,string>={prepare:'准备兼容镜像',verify:'校验',activate:'应用',rollback:'回退',import:'导入',export:'导出'}
 async function refresh(){if(!selected.value||busy.value||closed)return;const current=++generation;loading.value=true;try{const data=await apiRequest<ImageInventory>(base());if(current===generation)inventory.value=data}catch(e){if(current===generation)error.value=e instanceof Error?e.message:'无法读取镜像'}finally{loading.value=false}}
 async function action(operation:ImageOperation,imageId?:string){if(busy.value)return;busy.value=true;error.value='';try{await apiRequest(base()+'/jobs',{method:'POST',body:{requestId:createUuid(),operation,...(imageId?{imageId}:{}),...(operation==='import'?{uploadId:uploadId.value}:{})}})}catch(e){error.value=e instanceof Error?e.message:'镜像操作失败'}finally{busy.value=false;await refresh()}}
+async function sendChunk(endpoint:string,body:{offset:number;data:string}){
+ for(let attempt=0;;attempt++){if(closed)throw new Error('上传已取消');try{return await apiRequest<{offset:number}>(endpoint,{method:'POST',body})}catch(error){const status=Number((error as any).status);if(attempt>=2||(status>=400&&status<500))throw error;await new Promise(resolve=>setTimeout(resolve,300*(attempt+1)))}}
+}
 async function upload(){
  if(!archive.value||!manifest.value||busy.value)return;busy.value=true;error.value='';const file=archive.value;const endpoint=base();let success=false
  try{
@@ -16,7 +19,7 @@ async function upload(){
   const metadata=JSON.parse(await manifest.value.text());uploadId.value=createUuid()
   await apiRequest(endpoint+'/uploads',{method:'POST',body:{id:uploadId.value,size:file.size,manifest:metadata}})
   for(let offset=0;offset<file.size;){if(closed)throw new Error('上传已取消');const bytes=new Uint8Array(await file.slice(offset,offset+524288).arrayBuffer());let binary='';for(const byte of bytes)binary+=String.fromCharCode(byte)
-   const next=await apiRequest<{offset:number}>(endpoint+'/uploads/'+uploadId.value,{method:'POST',body:{offset,data:btoa(binary)}});if(next.offset<=offset)throw new Error('上传未能继续');offset=next.offset;progress.value=`正在上传 ${Math.round(offset/file.size*100)}%`
+   const next=await sendChunk(endpoint+'/uploads/'+uploadId.value,{offset,data:btoa(binary)});if(next.offset<=offset)throw new Error('上传未能继续');offset=next.offset;progress.value=`正在上传 ${Math.round(offset/file.size*100)}%`
   }success=true;progress.value='上传完成,正在提交导入'
  }catch(e){error.value=e instanceof Error?e.message:'上传失败';if(uploadId.value)await apiRequest(endpoint+'/uploads/'+uploadId.value,{method:'DELETE'}).catch(()=>{})}
  finally{busy.value=false}

+ 12 - 0
src/runner/agent.ts

@@ -229,6 +229,18 @@ export class RunnerAgent {
     const existing=this.inflight.get(command.id)
     const fingerprint=createHash('sha256').update(JSON.stringify(command)).digest('hex')
     const previous=this.db.prepare('SELECT * FROM commands WHERE id=?').get(command.id)
+    // Export chunks are immutable reads. Retain the fingerprint, not a second
+    // copy of a multi-gigabyte archive in the receipt database.
+    if(command.kind==='images.manage'&&command.payload.op==='download'){
+      if(previous&&previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
+      if(existing)return existing
+      if(!previous)this.db.prepare("INSERT INTO commands VALUES(?,?,'read',NULL,?)").run(command.id,fingerprint,Date.now())
+      const pending=Promise.resolve().then(async()=>{
+        if(command.expiresAt<=Date.now()||!(await this.api('admit',{id:command.id})).allowed||command.expiresAt<=Date.now())throw new HttpError(403,'读取授权已失效','runner_command_not_admitted')
+        return {result:await this.execute(command)}
+      }).catch(error=>({error:{message:error instanceof ComputerError||error instanceof HttpError?error.message:'镜像读取失败',code:error instanceof ComputerError||error instanceof HttpError?error.code:'runner_error'}})).finally(()=>this.inflight.delete(command.id))
+      this.inflight.set(command.id,pending);return pending
+    }
     if(previous){
       if(previous.fingerprint!==fingerprint)throw new HttpError(409,'命令编号已用于其他内容','idempotency_conflict')
       if(existing)return existing

+ 1 - 1
tests/e2e/team-tools.spec.ts

@@ -206,7 +206,7 @@ test('always exposes computer management and performs image and sharing actions
  await rows.first().getByRole('button',{name:'导出',exact:true}).click()
  await expect(hub.getByRole('link',{name:'下载镜像',exact:true})).toBeVisible()
  const download=page.waitForEvent('download');await hub.getByRole('link',{name:'下载镜像',exact:true}).click();expect((await download).suggestedFilename()).toBe('computer-image.tar')
- await page.setViewportSize({width:390,height:844});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await hub.getByRole('button',{name:'准备兼容镜像'}).scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('environment-images-mobile.png')})
+ await page.setViewportSize({width:375,height:844});await page.emulateMedia({colorScheme:'dark',reducedMotion:'reduce'});await hub.getByRole('button',{name:'准备兼容镜像'}).scrollIntoViewIfNeeded();await page.screenshot({path:testInfo.outputPath('environment-images-mobile.png')})
  await hub.getByRole('button',{name:'共享环境',exact:true}).click();await expect(hub.getByText('创建共享环境',{exact:true}).first()).toBeVisible()
  await hub.getByRole('button',{name:'关闭',exact:true}).click()
  await page.route('**/api/app/computers',route=>route.fulfill({status:404,contentType:'application/json',body:'{"error":"old server"}'}))

+ 13 - 13
tests/live/worker.live.test.ts

@@ -75,8 +75,8 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   const server=createServer(app.app.callback());await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));app.config.port=(server.address() as {port:number}).port
   const address=`http://127.0.0.1:${app.config.port}`,abort=new AbortController();let running:Promise<void>|undefined
   try{
-    const bootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).expect(200)
-    const setup=await request(app.app.callback()).post('/api/app/setup').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'worker-fixture',password:'worker-fixture-password'}).expect(200)
+    const bootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).expect(200)
+    const setup=await request(address).post('/api/app/setup').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'worker-fixture',password:'worker-fixture-password'}).expect(200)
     const owner=setup.body.user.id,registered=app.runners.enroll(owner,{name:'Worker 验收节点',allowedProfiles:['default']})
     const runnerConfig={protocol:1,serverURL:address,runnerId:registered.runner.id,token:registered.token,hermesURL:upstreamURL,allowedProfiles:['default'],artifactRoots:[],computers:{network:process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'public-proxy':'none',runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome}}
     if(process.env.YAOYAO_WORKER_RUNNER_BUNDLE){
@@ -90,10 +90,10 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     void running.catch(()=>{})
     await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
     const imageCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
-    const imageBootstrap=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)
+    const imageBootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)
     const imageBase=`/api/app/admin/runners/${registered.runner.id}/images`
-    const imagesGet=async()=>(await request(app.app.callback()).get(imageBase).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
-    const imagesPost=(body:unknown)=>request(app.app.callback()).post(imageBase+'/jobs').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
+    const imagesGet=async()=>(await request(address).get(imageBase).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
+    const imagesPost=(body:unknown)=>request(address).post(imageBase+'/jobs').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
     const prepareId=randomUUID();await imagesPost({requestId:prepareId,operation:'prepare'}).expect(202)
     let prepared:any
     await expect.poll(async()=>{prepared=(await imagesGet()).jobs.find((j:any)=>j.id===prepareId);return prepared?.state},{timeout:90000}).toMatch(/complete|failed/)
@@ -108,11 +108,11 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     let exportedImage:any
     await expect.poll(async()=>{exportedImage=(await imagesGet()).jobs.find((j:any)=>j.id===exportId);return exportedImage?.state},{timeout:60000}).toBe('complete')
     const uploadId=randomUUID()
-    const uploadAPI=(path:string,body:unknown)=>request(app.app.callback()).post(imageBase+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
+    const uploadAPI=(path:string,body:unknown)=>request(address).post(imageBase+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',imageCookies).set('X-CSRF-Token',imageBootstrap.body.csrfToken).send(body)
     await uploadAPI('/uploads',{id:uploadId,size:exportedImage.archiveSize,manifest:exportedImage.manifest}).expect(200)
     const {createHash}=await import('node:crypto');const archiveHash=createHash('sha256')
     for(let offset=0;offset<exportedImage.archiveSize;){
-      const part=(await request(app.app.callback()).get(imageBase+`/jobs/${exportId}/chunk?offset=${offset}`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
+      const part=(await request(address).get(imageBase+`/jobs/${exportId}/chunk?offset=${offset}`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',imageCookies).expect(200)).body
       const bytes=Buffer.from(part.data,'base64');archiveHash.update(bytes)
       const accepted=await uploadAPI(`/uploads/${uploadId}`,{offset,data:part.data}).expect(200);offset+=bytes.length;expect(accepted.body.offset).toBe(offset)
     }
@@ -131,7 +131,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     expect(message.attachments).toHaveLength(2)
     const report=message.attachments.find(file=>file.name==='result.txt')!
     expect(report).toBeDefined()
-    const download=await request(app.app.callback()).get(`/api/app/files/${report.id}/download`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).expect(200)
+    const download=await request(address).get(`/api/app/files/${report.id}/download`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).expect(200)
     expect(download.text).toBe('worker-proof')
     const files=app.workspace.list<any>(owner,'file');expect(await readFile(files.find(file=>file.name==='result.txt').path,'utf8')).toBe('worker-proof')
     const bulk=files.find(file=>file.name==='bulk.bin');expect((await readFile(bulk.path)).length).toBe(2100000)
@@ -146,14 +146,14 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     const controlled=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:control'})
     await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===controlled.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)
     const cookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
-    const fresh=await request(app.app.callback()).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies).expect(200)
+    const fresh=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies).expect(200)
     const endpoint=`/api/app/agents/${worker.id}/computer`
-    const api=(path:string,data:unknown)=>request(app.app.callback()).post(endpoint+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
+    const api=(path:string,data:unknown)=>request(address).post(endpoint+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
     const taken=await api('/take',{requestId:randomUUID()}).expect(200),ticket={controlId:taken.body.controlId,token:taken.body.token}
-    const status=async()=>(await request(app.app.callback()).get(endpoint).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies)).body
+    const status=async()=>(await request(address).get(endpoint).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies)).body
     await expect.poll(async()=>(await status()).mode,{timeout:30000}).toBe('human')
     expect(app.workspace.require<any>(owner,'run',controlled.id).status).toBe('waiting')
-    const capture=async()=>(await request(app.app.callback()).get(endpoint+'/frame').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies)).body
+    const capture=async()=>(await request(address).get(endpoint+'/frame').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies)).body
     let frame:any
     await expect.poll(async()=>{frame=await capture();return frame.width},{timeout:20000}).toBeGreaterThan(0)
     const click=await api('/input',{...ticket,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'click',x:568,y:875}}).expect(200)
@@ -175,7 +175,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     await expect(access(join(runnerHome,'computer-workspaces',worker.id,'late-write.txt'))).rejects.toMatchObject({code:'ENOENT'})
     const second=app.workspace.createAgent(owner,{name:'共享核验成员',profile:'default',execution:'computer'})
     const secondChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===second.id)!
-    const sharedAPI=(method:'post'|'delete',path:string,data:unknown)=>request(app.app.callback())[method](path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
+    const sharedAPI=(method:'post'|'delete',path:string,data:unknown)=>request(address)[method](path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
     const shared=await sharedAPI('post','/api/app/computers/shared',{requestId:randomUUID(),name:'真实共享电脑',memberIds:[worker.id,second.id],trusted:true}).expect(201)
     const sharedWrite=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:shared-write'})
     await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===sharedWrite.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)

+ 12 - 0
tests/server/runner.test.ts

@@ -264,3 +264,15 @@ it('keeps only stop operations available on an existing channel after task permi
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
 })
+it('keeps image download receipts small and reauthorizes immutable chunk retries',async()=>{
+ const original=(runner as any).api.bind(runner)
+ const api=vi.spyOn(runner as any,'api').mockImplementation(async(...args:any[])=>args[0]==='admit'?{allowed:true}:original(...args))
+ const execute=vi.spyOn(runner as any,'execute').mockResolvedValue({data:Buffer.alloc(524288).toString('base64'),offset:0,size:524288})
+ try{
+  const command:RunnerCommand={id:randomUUID(),kind:'images.manage',payload:{op:'download',owner:'fixture',id:randomUUID(),offset:0},expiresAt:Date.now()+30000}
+  expect(await runner.handle(command)).toHaveProperty('result.data')
+  expect((runner as any).db.prepare('SELECT state,result FROM commands WHERE id=?').get(command.id)).toMatchObject({state:'read',result:null})
+  expect(await runner.handle(command)).toHaveProperty('result.data');expect(execute).toHaveBeenCalledTimes(2)
+  await expect(runner.handle({...command,payload:{...command.payload,offset:1}})).rejects.toMatchObject({code:'idempotency_conflict'})
+ }finally{api.mockRestore();execute.mockRestore()}
+})