toolOperationJournal.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. import {createHash} from 'node:crypto'
  2. import {HttpError} from './errors.js'
  3. import type {WorkspaceStore} from './workspaceStore.js'
  4. interface ResultSummary {cached:boolean;bytes:number;sha256:string;reason?:'observation'|'binary'|'size'}
  5. interface Receipt {id:string;workId:string;name:string;fingerprint:string;state:'started'|'complete'|'failed';startedAt:number;completedAt?:number;result?:unknown;resultSummary?:ResultSummary;error?:{message:string;code:string;status:number}}
  6. const canonical=(value:unknown):unknown=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.entries(value).sort(([a],[b])=>a.localeCompare(b)).map(([key,item])=>[key,canonical(item)])):value
  7. const MAX_CACHED_RESULT_BYTES=16*1024
  8. const observations=new Set(['computer_read_file','computer_desktop_state','managed_browser_state','managed_browser_snapshot'])
  9. function observation(name:string,args:unknown){
  10. if(observations.has(name))return true
  11. if(name!=='managed_browser_action'||!args||typeof args!=='object')return false
  12. const action=(args as {action?:unknown}).action
  13. return !!action&&typeof action==='object'&&['state','downloads','snapshot','screenshot'].includes(String((action as {kind?:unknown}).kind))
  14. }
  15. function containsBinary(value:unknown):boolean{
  16. if(Buffer.isBuffer(value))return true
  17. if(typeof value==='string')return value.startsWith('data:')&&value.includes(';base64,')
  18. if(!value||typeof value!=='object')return false
  19. if(Array.isArray(value))return value.some(containsBinary)
  20. const item=value as Record<string,unknown>
  21. if(typeof item.blob==='string'||typeof item.data==='string'&&(typeof item.mimeType==='string'||item.type==='image'||item.type==='audio'))return true
  22. return Object.values(item).some(containsBinary)
  23. }
  24. /** Persist intent before effects. Missing replies are never interpreted as permission to replay. */
  25. export class ToolOperationJournal {
  26. private running=new Map<string,Promise<unknown>>()
  27. constructor(private readonly store:WorkspaceStore){}
  28. async execute<T>(owner:string,workId:string,callId:string|undefined,name:string,args:unknown,authorize:()=>void,action:()=>Promise<T>):Promise<T>{
  29. authorize()
  30. if(!callId)return action()
  31. if(callId.length>512)throw new HttpError(400,'工具调用编号过长','invalid_operation_id')
  32. const id=createHash('sha256').update(JSON.stringify([workId,callId])).digest('hex'),key=owner+':'+id
  33. const fingerprint=createHash('sha256').update(JSON.stringify(canonical([name,args]))).digest('hex')
  34. const old=this.store.get<Receipt>(owner,'tool-operation',id)
  35. if(old){
  36. if(old.fingerprint!==fingerprint)throw new HttpError(409,'工具调用编号已用于不同参数','idempotency_conflict')
  37. const pending=this.running.get(key);if(pending)return pending as Promise<T>
  38. if(old.state==='complete'&&'result' in old)return old.result as T
  39. if(old.state==='complete')throw new HttpError(409,'原操作已完成,回执已保留但未缓存完整结果,不能按同一编号重做;如需最新观察,请发起新的读取操作','tool_operation_result_unavailable')
  40. if(old.state==='failed'&&old.error)throw new HttpError(old.error.status,old.error.message,old.error.code)
  41. throw new HttpError(409,'原操作已提交但结果不确定,请核对环境,不能自动重做','tool_operation_uncertain')
  42. }
  43. const receipt:Receipt={id,workId,name,fingerprint,state:'started',startedAt:Date.now()}
  44. this.store.put(owner,'tool-operation',id,receipt)
  45. const pending=Promise.resolve().then(async()=>{
  46. authorize()
  47. const result=await action()
  48. // Receipts prove completion without making the workspace database a
  49. // second screenshot/file store. Only small, non-observation replies are
  50. // replayable; omitting a reply never authorizes repeating its effects.
  51. const encoded=JSON.stringify(result??null)
  52. const bytes=Buffer.byteLength(encoded),reason=observation(name,args)?'observation':bytes>MAX_CACHED_RESULT_BYTES?'size':containsBinary(result)?'binary':undefined
  53. const resultSummary:ResultSummary={cached:reason===undefined,bytes,sha256:createHash('sha256').update(encoded).digest('hex'),...(reason?{reason}:{})}
  54. this.store.put(owner,'tool-operation',id,{...receipt,state:'complete',completedAt:Date.now(),resultSummary,...(resultSummary.cached?{result:result??null}:{})})
  55. authorize();return result
  56. }).catch(error=>{
  57. // A transport failure can follow an external side effect; record the error,
  58. // but the same operation ID always returns it instead of executing again.
  59. this.store.put(owner,'tool-operation',id,{...receipt,state:'failed',completedAt:Date.now(),error:{message:(error instanceof Error?error.message:'工具执行失败').slice(0,2048),code:(error instanceof HttpError?error.code:'tool_operation_failed').slice(0,128),status:error instanceof HttpError?error.status:500}})
  60. throw error
  61. }).finally(()=>this.running.delete(key))
  62. this.running.set(key,pending);return pending
  63. }
  64. }