workspaceRoutines.ts 8.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. import Router from '@koa/router'
  2. import {randomUUID} from 'node:crypto'
  3. import {z} from 'zod'
  4. import {parse,type WorkspaceStore} from './workspaceStore.js'
  5. import type {LocalAuthStore} from './localAuth.js'
  6. import type {WorkspaceNodes} from './workspaceGateway.js'
  7. import type {WorkspaceRuntime} from './workspaceRuntime.js'
  8. import type {WorkspaceAgent,WorkspaceConversation,WorkspaceRun} from '../shared/workspace.js'
  9. import type {WorkspaceRoutine,WorkspaceSchedule,WorkspaceRoutineRun} from '../shared/workspacePanels.js'
  10. import {HttpError} from './errors.js'
  11. const timezone=z.string().max(100).refine(value=>{try{new Intl.DateTimeFormat('en',{timeZone:value});return true}catch{return false}},'时区无效')
  12. const clock=z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/)
  13. const schedule=z.discriminatedUnion('kind',[
  14. z.object({kind:z.literal('once'),timezone,at:z.number().int().positive()}).strict(),
  15. z.object({kind:z.literal('interval'),timezone,everyMinutes:z.number().int().min(1).max(525600)}).strict(),
  16. z.object({kind:z.literal('daily'),timezone,time:clock}).strict(),
  17. z.object({kind:z.literal('weekly'),timezone,time:clock,weekdays:z.array(z.number().int().min(0).max(6)).min(1).max(7)}).strict(),
  18. ])
  19. const input=z.object({name:z.string().trim().min(1).max(100),prompt:z.string().trim().min(1).max(24000),enabled:z.boolean(),schedule}).strict()
  20. export function nextRoutineAt(s:WorkspaceSchedule,after:number):number|undefined {
  21. if(s.kind==='once')return s.at!>after?s.at:undefined
  22. if(s.kind==='interval')return after+s.everyMinutes!*60000
  23. const formatter=new Intl.DateTimeFormat('en-US',{timeZone:s.timezone,hourCycle:'h23',hour:'2-digit',minute:'2-digit',weekday:'short'})
  24. for(let at=Math.floor(after/60000)*60000+60000;at<=after+8*86400000;at+=60000){
  25. const parts=Object.fromEntries(formatter.formatToParts(at).map(p=>[p.type,p.value]))
  26. if(`${parts.hour}:${parts.minute}`===s.time&&(s.kind==='daily'||s.weekdays!.includes(['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday!))))return at
  27. }
  28. throw new HttpError(400,'无法计算下次执行时间','routine_schedule_invalid')
  29. }
  30. const terminal=(status:string)=>['complete','failed','interrupted','skipped'].includes(status)
  31. export class WorkspaceRoutines {
  32. private timer?:ReturnType<typeof setInterval>;private ticking=false;private closed=false
  33. constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly runtime:WorkspaceRuntime){}
  34. private agent(owner:string,id:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',id);if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId)throw new HttpError(409,'此机器人不能建立定时任务','routine_agent_unavailable');this.nodes.requireSource(owner,agent);return agent}
  35. save(owner:string,agentId:string,body:unknown,id:string=randomUUID()){
  36. this.agent(owner,agentId);const value=parse(input,body),old=this.store.get<WorkspaceRoutine>(owner,'routine',id)
  37. if(old&&old.agentId!==agentId)throw new HttpError(404,'定时任务不存在','not_found')
  38. const now=Date.now(),unchanged=!!old&&old.enabled===value.enabled&&JSON.stringify(parse(schedule,old.schedule))===JSON.stringify(value.schedule),nextAt=unchanged?old!.nextAt:value.enabled?nextRoutineAt(value.schedule,now):undefined
  39. if(value.enabled&&!nextAt)throw new HttpError(400,'请选择未来的执行时间','routine_time_invalid')
  40. const routine:WorkspaceRoutine={...value,id,agentId,nextAt,createdAt:old?.createdAt??now,updatedAt:now,lastAt:old?.lastAt}
  41. this.store.put(owner,'routine',id,routine);this.store.event(owner,'routine.changed',routine);return routine
  42. }
  43. runs(owner:string,agentId:string){return this.store.list<WorkspaceRoutineRun>(owner,'routine-run').filter(r=>r.agentId===agentId).map(r=>{
  44. const run=r.runId?this.store.get<WorkspaceRun>(owner,'run',r.runId):undefined
  45. return run?{...r,status:run.status,error:run.error}:r
  46. }).sort((a,b)=>b.startedAt-a.startedAt).slice(0,100)}
  47. run(owner:string,routine:WorkspaceRoutine,scheduledAt:number,requestId:string=randomUUID()){
  48. return this.store.command(owner,requestId,{operation:'routine.run',routineId:routine.id,scheduledAt},()=>this.store.atomic(()=>{
  49. this.agent(owner,routine.agentId)
  50. const id=randomUUID(),entry:WorkspaceRoutineRun={id,routineId:routine.id,agentId:routine.agentId,scheduledAt,startedAt:Date.now(),status:'queued'}
  51. try {
  52. if(this.store.list<WorkspaceRoutineRun>(owner,'routine-run').some(r=>r.routineId===routine.id&&!terminal((r.runId?this.store.get<WorkspaceRun>(owner,'run',r.runId)?.status:undefined)??r.status)))throw new HttpError(409,'上次定时任务仍在执行,本次已跳过','routine_overlap')
  53. const conversation=this.store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&!c.archived&&c.memberIds[0]===routine.agentId)
  54. if(!conversation)throw new HttpError(409,'机器人的聊天已归档或删除','routine_conversation_unavailable')
  55. const run=this.runtime.send(owner,conversation.id,{requestId:id,content:routine.prompt})
  56. Object.assign(entry,{runId:run.id,conversationId:conversation.id})
  57. }catch(error){entry.status=error instanceof HttpError&&error.code==='routine_overlap'?'skipped':'failed';entry.error=error instanceof Error?error.message:'定时任务未能启动'}
  58. this.store.put(owner,'routine-run',id,entry)
  59. const old=this.store.list<WorkspaceRoutineRun>(owner,'routine-run').filter(r=>r.agentId===routine.agentId).sort((a,b)=>b.startedAt-a.startedAt).slice(100)
  60. for(const run of old)if(terminal(this.store.get<WorkspaceRun>(owner,'run',run.runId??'')?.status??run.status))this.store.remove(owner,'routine-run',run.id)
  61. return entry
  62. }))
  63. }
  64. tick(now=Date.now()){
  65. if(this.closed||this.ticking)return;this.ticking=true
  66. try {for(const owner of this.store.owners())for(const routine of this.store.list<WorkspaceRoutine>(owner,'routine')){
  67. if(!routine.enabled||!routine.nextAt||routine.nextAt>now)continue
  68. this.store.atomic(()=>{
  69. const due=routine.nextAt!
  70. try{if(!this.auth.isUserActive(owner))throw new Error('账号当前不可用');this.run(owner,routine,due)}catch(error){const id=randomUUID();routine.enabled=false;this.store.put(owner,'routine-run',id,{id,routineId:routine.id,agentId:routine.agentId,scheduledAt:due,startedAt:now,status:'failed',error:error instanceof Error?error.message:'定时任务未能启动'})}
  71. routine.lastAt=due;routine.nextAt=routine.enabled?nextRoutineAt(routine.schedule,now):undefined;if(!routine.nextAt)routine.enabled=false
  72. routine.updatedAt=now;this.store.put(owner,'routine',routine.id,routine);this.store.event(owner,'routine.changed',routine)
  73. })
  74. }}finally{this.ticking=false}
  75. }
  76. start(){if(this.timer)return;this.tick();this.timer=setInterval(()=>this.tick(),10000);this.timer.unref()}
  77. close(){this.closed=true;clearInterval(this.timer)}
  78. router(){const router=new Router()
  79. router.get('/api/app/agents/:id/routines',ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.body={routines:this.store.list<WorkspaceRoutine>(owner,'routine').filter(r=>r.agentId===ctx.params.id),runs:this.runs(owner,ctx.params.id)}})
  80. router.post('/api/app/agents/:id/routines',ctx=>{ctx.body={routine:this.save(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body)};ctx.status=201})
  81. router.put('/api/app/agents/:id/routines/:routineId',ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'routine',ctx.params.routineId);ctx.body={routine:this.save(owner,ctx.params.id,(ctx.request as any).body,ctx.params.routineId)}})
  82. router.delete('/api/app/agents/:id/routines/:routineId',ctx=>{const owner=this.auth.require(ctx).id,r=this.store.require<WorkspaceRoutine>(owner,'routine',ctx.params.routineId);if(r.agentId!==ctx.params.id)throw new HttpError(404,'定时任务不存在','not_found');this.store.remove(owner,'routine',r.id);ctx.body={ok:true}})
  83. router.post('/api/app/agents/:id/routines/:routineId/run',ctx=>{const owner=this.auth.require(ctx).id,r=this.store.require<WorkspaceRoutine>(owner,'routine',ctx.params.routineId);if(r.agentId!==ctx.params.id)throw new HttpError(404,'定时任务不存在','not_found');const body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body);ctx.body={run:this.run(owner,r,0,body.requestId)}})
  84. return router
  85. }
  86. }