workspaceLifecycleCleanup.test.ts 6.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. // @vitest-environment node
  2. import {afterEach,beforeEach,expect,it,vi} from 'vitest'
  3. import {mkdtempSync,rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {randomUUID} from 'node:crypto'
  7. import {OpenVikingClient} from '@openviking/sdk'
  8. import {WorkspaceStore} from '../../src/server/workspaceStore'
  9. import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
  10. import {WorkspaceNodes} from '../../src/server/workspaceGateway'
  11. import {UploadStore} from '../../src/server/uploads'
  12. import {OpenVikingConfigurationManager} from '../../src/server/openVikingConfiguration'
  13. import {OpenVikingService} from '../../src/server/openVikingService'
  14. import type {WorkspaceAgent,WorkspaceConversation} from '../../src/shared/workspace'
  15. const owner='lifecycle-owner'
  16. let home:string,store:WorkspaceStore,uploads:UploadStore,runtime:WorkspaceRuntime,service:OpenVikingService,manager:OpenVikingConfigurationManager,bot:WorkspaceAgent,conversation:WorkspaceConversation
  17. const admin={adminRegisterUser:vi.fn(async()=>({user_key:'temporary-test-key'})),adminRemoveUser:vi.fn(async()=>({ok:true}))}
  18. const nodes={requireSource:()=>{}} as unknown as WorkspaceNodes
  19. beforeEach(async()=>{
  20. home=mkdtempSync(join(tmpdir(),'yaoyao-cleanup-test-'));store=new WorkspaceStore(home);uploads=new UploadStore(home)
  21. manager=new OpenVikingConfigurationManager(home,undefined,async()=>undefined)
  22. await manager.update({enabled:true,url:'http://127.0.0.1:1933',accountId:'fixture',adminKey:'temporary-test-admin'})
  23. service=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
  24. runtime=new WorkspaceRuntime(store,nodes,uploads,undefined,undefined,service)
  25. vi.spyOn(runtime,'wake').mockImplementation(()=>{})
  26. bot=store.createAgent(owner,{name:'待删除 Bot',profile:'default'})
  27. conversation=store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
  28. })
  29. afterEach(()=>{runtime.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true});vi.clearAllMocks()})
  30. it('validates every local delete condition before removing any remote memory',async()=>{
  31. await service.ensureUser(owner,bot)
  32. await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'agent_not_archived'})
  33. const peer=store.createAgent(owner,{name:'群成员',profile:'default'})
  34. const group=store.createGroup(owner,{name:'现有群',memberIds:[bot.id,peer.id],administratorId:peer.id})
  35. store.updateAgent(owner,bot.id,{archived:true})
  36. await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'agent_in_group'})
  37. expect(store.require<WorkspaceConversation>(owner,'conversation',group.id).memberIds).toContain(bot.id)
  38. expect(admin.adminRemoveUser).not.toHaveBeenCalled()
  39. expect(service.binding(owner,bot.id)?.status).toBe('active')
  40. expect(store.get(owner,'agent-deletion',bot.id)).toBeUndefined()
  41. })
  42. it('preserves the stop ledger and session binding, and blocks deletion and source reuse until cleanup',()=>{
  43. store.updateAgent(owner,bot.id,{archived:true})
  44. const turnId=randomUUID(),bindingId=`${conversation.id}:${bot.id}`
  45. store.put(owner,'turn',turnId,{id:turnId,agentId:bot.id,conversationId:conversation.id,status:'interrupted',cleanupPending:true})
  46. store.put(owner,'binding',bindingId,{id:bindingId,conversationId:conversation.id,agentId:bot.id,storedId:'original-session'})
  47. expect(()=>store.deleteAgent(owner,bot.id)).toThrow('尚未确认停止')
  48. expect(()=>store.updateAgent(owner,bot.id,{profile:'changed'})).toThrow('确认当前任务停止')
  49. expect(()=>store.deleteConversation(owner,conversation.id,true)).toThrow('尚未确认停止')
  50. expect(store.require<any>(owner,'binding',bindingId).storedId).toBe('original-session')
  51. const turn=store.require<any>(owner,'turn',turnId);turn.cleanupPending=false;store.put(owner,'turn',turnId,turn)
  52. store.deleteAgent(owner,bot.id)
  53. expect(store.get(owner,'turn',turnId)).toBeUndefined()
  54. expect(store.require<any>(owner,'agent-deletion',bot.id).conversationIds).toEqual([conversation.id])
  55. expect(()=>store.createAgent(owner,{name:'复用旧 ID',profile:'default'},undefined,bot.id)).toThrow('不能复用')
  56. })
  57. it('keeps a failed remote deletion retryable by the same Bot id after local deletion',async()=>{
  58. await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
  59. admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture temporarily unavailable'))
  60. await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'openviking_unavailable'})
  61. expect(store.get(owner,'agent',bot.id)).toBeUndefined()
  62. expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(true)
  63. await runtime.deleteAgent(owner,bot.id);await runtime.deleteAgent(owner,bot.id)
  64. expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false)
  65. expect(admin.adminRemoveUser).toHaveBeenCalledTimes(2)
  66. })
  67. it('automatically recovers persisted remote cleanup after restart and retries transient failure',async()=>{
  68. await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
  69. admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture initial failure'))
  70. await expect(runtime.deleteAgent(owner,bot.id)).rejects.toThrow('fixture initial failure')
  71. runtime.close()
  72. service=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
  73. runtime=new WorkspaceRuntime(store,nodes,uploads,undefined,undefined,service)
  74. admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture retry failure'))
  75. runtime.start()
  76. await vi.waitFor(()=>expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false),{timeout:3000})
  77. expect(service.binding(owner,bot.id)).toMatchObject({status:'removed',pendingRemoval:false})
  78. expect(admin.adminRemoveUser).toHaveBeenCalledTimes(3)
  79. expect(admin.adminRegisterUser).toHaveBeenCalledTimes(1)
  80. })
  81. it('keeps cleanup durable while disabled and resumes it when the configured service returns',async()=>{
  82. await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
  83. service.configure(undefined)
  84. await runtime.deleteAgent(owner,bot.id)
  85. expect(admin.adminRemoveUser).not.toHaveBeenCalled()
  86. expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(true)
  87. vi.mocked(runtime.wake).mockRestore()
  88. service.configure(manager.configuration())
  89. await vi.waitFor(()=>expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false))
  90. expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
  91. })