toolOperationJournal.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. // @vitest-environment node
  2. import {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 {WorkspaceStore} from '../../src/server/workspaceStore'
  7. import {ToolOperationJournal} from '../../src/server/toolOperationJournal'
  8. import {HttpError} from '../../src/server/errors'
  9. import {createHash} from 'node:crypto'
  10. function fixture(){
  11. const home=mkdtempSync(join(tmpdir(),'yaoyao-operation-journal-')),store=new WorkspaceStore(home),journal=new ToolOperationJournal(store)
  12. return {store,journal,close(){store.close();rmSync(home,{recursive:true,force:true})}}
  13. }
  14. const defer=()=>{let resolve!:()=>void;const promise=new Promise<void>(done=>{resolve=done});return {promise,resolve}}
  15. it('joins duplicate in-flight operations and reuses a durable reply across instances',async()=>{
  16. const f=fixture(),gate=defer(),action=vi.fn(async()=>{await gate.promise;return {fileId:'published'}})
  17. try{
  18. const first=f.journal.execute('owner','work','call','export',{downloadId:'one'},()=>{},action)
  19. const concurrent=f.journal.execute('owner','work','call','export',{downloadId:'one'},()=>{},action)
  20. gate.resolve()
  21. expect(await first).toEqual({fileId:'published'});expect(await concurrent).toEqual({fileId:'published'})
  22. const reopened=new ToolOperationJournal(f.store)
  23. expect(await reopened.execute('owner','work','call','export',{downloadId:'one'},()=>{},action)).toEqual({fileId:'published'})
  24. expect(action).toHaveBeenCalledTimes(1)
  25. expect(f.store.list<any>('owner','tool-operation')).toMatchObject([{state:'complete',result:{fileId:'published'}}])
  26. }finally{gate.resolve();await Promise.resolve();f.close()}
  27. })
  28. it('canonicalizes object keys but preserves action identity, array order and changed arguments',async()=>{
  29. const f=fixture(),action=vi.fn(async()=>({ok:true}))
  30. try{
  31. await f.journal.execute('owner','work','call','action',{a:1,b:{x:2,y:['first','second']}},()=>{},action)
  32. await f.journal.execute('owner','work','call','action',{b:{y:['first','second'],x:2},a:1},()=>{},action)
  33. expect(action).toHaveBeenCalledTimes(1)
  34. for(const [name,args] of [['other',{a:1,b:{x:2,y:['first','second']}}],['action',{a:1,b:{x:2,y:['second','first']}}],['action',{a:2,b:{x:2,y:['first','second']}}]] as const){
  35. await expect(f.journal.execute('owner','work','call',name,args,()=>{},action)).rejects.toMatchObject({code:'idempotency_conflict'})
  36. }
  37. expect(action).toHaveBeenCalledTimes(1)
  38. }finally{f.close()}
  39. })
  40. it('scopes stable IDs to both owner and task',async()=>{
  41. const f=fixture(),action=vi.fn(async()=>({ok:true}))
  42. try{
  43. for(const [owner,work] of [['one','work'],['two','work'],['one','other-work']])await f.journal.execute(owner!,work!,'same-call','action',{},()=>{},action)
  44. expect(action).toHaveBeenCalledTimes(3)
  45. expect(f.store.list('one','tool-operation')).toHaveLength(2)
  46. expect(f.store.list('two','tool-operation')).toHaveLength(1)
  47. }finally{f.close()}
  48. })
  49. it('does not replay a submitted operation whose original process has not produced a receipt',async()=>{
  50. const f=fixture(),gate=defer(),action=vi.fn(async()=>{await gate.promise;return {ok:true}})
  51. try{
  52. const original=f.journal.execute('owner','work','call','submit',{},()=>{},action)
  53. await Promise.resolve()
  54. const restarted=new ToolOperationJournal(f.store)
  55. await expect(restarted.execute('owner','work','call','submit',{},()=>{},action)).rejects.toMatchObject({code:'tool_operation_uncertain'})
  56. expect(action).toHaveBeenCalledTimes(1)
  57. gate.resolve();await original
  58. }finally{gate.resolve();await Promise.resolve();f.close()}
  59. })
  60. it('persists failures and never repeats an external effect after a missing reply',async()=>{
  61. const f=fixture(),effect=vi.fn(async()=>{throw new HttpError(504,'已发送,结果未知','transport_uncertain')})
  62. try{
  63. await expect(f.journal.execute('owner','work','call','submit',{},()=>{},effect)).rejects.toMatchObject({code:'transport_uncertain'})
  64. const restarted=new ToolOperationJournal(f.store)
  65. await expect(restarted.execute('owner','work','call','submit',{},()=>{},effect)).rejects.toMatchObject({status:504,code:'transport_uncertain'})
  66. expect(effect).toHaveBeenCalledTimes(1)
  67. }finally{f.close()}
  68. })
  69. it('checks authorization even when returning a cached receipt and after an effect finishes',async()=>{
  70. const f=fixture(),action=vi.fn(async()=>({ok:true}))
  71. let allowed=true
  72. const authorize=()=>{if(!allowed)throw new HttpError(403,'任务已撤销','revoked')}
  73. try{
  74. await f.journal.execute('owner','work','cached','submit',{},authorize,action)
  75. allowed=false
  76. await expect(f.journal.execute('owner','work','cached','submit',{},authorize,action)).rejects.toMatchObject({code:'revoked'})
  77. expect(action).toHaveBeenCalledTimes(1)
  78. allowed=true
  79. const revokeDuring=vi.fn(async()=>{allowed=false;return {ok:true}})
  80. await expect(f.journal.execute('owner','work','late-revoke','submit',{},authorize,revokeDuring)).rejects.toMatchObject({code:'revoked'})
  81. allowed=true
  82. await expect(f.journal.execute('owner','work','late-revoke','submit',{},authorize,revokeDuring)).rejects.toMatchObject({code:'revoked'})
  83. expect(revokeDuring).toHaveBeenCalledTimes(1)
  84. }finally{f.close()}
  85. })
  86. it('keeps a bounded receipt for a large command reply and never replays its side effect after restart',async()=>{
  87. const f=fixture(),value={stdout:'文件正文'.repeat(4096)},action=vi.fn(async()=>value),encoded=JSON.stringify(value)
  88. try{
  89. expect(await f.journal.execute('owner','work','call','computer_shell',{},()=>{},action)).toBe(value)
  90. const receipt=f.store.list<any>('owner','tool-operation')[0]
  91. expect(receipt.state).toBe('complete');expect(receipt).not.toHaveProperty('result')
  92. expect(receipt.resultSummary).toEqual({cached:false,reason:'size',bytes:Buffer.byteLength(encoded),sha256:createHash('sha256').update(encoded).digest('hex')})
  93. expect(Buffer.byteLength(JSON.stringify(receipt))).toBeLessThan(1024)
  94. const restarted=new ToolOperationJournal(f.store)
  95. await expect(restarted.execute('owner','work','call','computer_shell',{},()=>{},action)).rejects.toMatchObject({code:'tool_operation_result_unavailable'})
  96. expect(action).toHaveBeenCalledTimes(1)
  97. }finally{f.close()}
  98. })
  99. it.each([
  100. ['computer_read_file',{}],['computer_desktop_state',{}],['managed_browser_state',{}],['managed_browser_snapshot',{}],
  101. ['managed_browser_action',{action:{kind:'screenshot'}}],
  102. ] as const)('records observation evidence without retaining the %s payload',async(name,args)=>{
  103. const f=fixture(),action=vi.fn(async()=>({content:'private-observation'}))
  104. try{
  105. expect(await f.journal.execute('owner','work','call',name,args,()=>{},action)).toEqual({content:'private-observation'})
  106. const receipt=f.store.list<any>('owner','tool-operation')[0]
  107. expect(receipt).toMatchObject({state:'complete',resultSummary:{cached:false,reason:'observation'}})
  108. expect(receipt).not.toHaveProperty('result')
  109. expect(JSON.stringify(receipt)).not.toContain('private-observation')
  110. await expect(new ToolOperationJournal(f.store).execute('owner','work','call',name,args,()=>{},action)).rejects.toMatchObject({code:'tool_operation_result_unavailable'})
  111. expect(action).toHaveBeenCalledTimes(1)
  112. }finally{f.close()}
  113. })
  114. it('shares an uncached binary reply in flight but persists only evidence, even for a small image',async()=>{
  115. const f=fixture(),gate=defer(),value={content:[{type:'image',mimeType:'image/png',data:'private-base64'}]},action=vi.fn(async()=>{await gate.promise;return value})
  116. try{
  117. const first=f.journal.execute('owner','work','call','some_effect',{},()=>{},action)
  118. const duplicate=f.journal.execute('owner','work','call','some_effect',{},()=>{},action)
  119. gate.resolve()
  120. expect(await first).toBe(value);expect(await duplicate).toBe(value)
  121. const receipt=f.store.list<any>('owner','tool-operation')[0]
  122. expect(receipt).toMatchObject({state:'complete',resultSummary:{cached:false,reason:'binary'}})
  123. expect(JSON.stringify(receipt)).not.toContain('private-base64')
  124. await expect(new ToolOperationJournal(f.store).execute('owner','work','call','some_effect',{},()=>{},action)).rejects.toMatchObject({code:'tool_operation_result_unavailable'})
  125. expect(action).toHaveBeenCalledTimes(1)
  126. }finally{gate.resolve();await Promise.resolve();f.close()}
  127. })
  128. it('bounds error receipts without permitting a failed side effect to execute again',async()=>{
  129. const f=fixture(),action=vi.fn(async()=>{throw new HttpError(500,'x'.repeat(100000),'failed_effect')})
  130. try{
  131. await expect(f.journal.execute('owner','work','call','computer_shell',{},()=>{},action)).rejects.toMatchObject({code:'failed_effect'})
  132. const receipt=f.store.list<any>('owner','tool-operation')[0]
  133. expect(receipt.state).toBe('failed')
  134. expect(receipt.error.message.length).toBeLessThanOrEqual(2048)
  135. await expect(new ToolOperationJournal(f.store).execute('owner','work','call','computer_shell',{},()=>{},action)).rejects.toMatchObject({code:'failed_effect'})
  136. expect(action).toHaveBeenCalledTimes(1)
  137. }finally{f.close()}
  138. })