service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. // 定时任务(docs/design/14-schedules.md):在对话中由 AI 创建(用户确认),属于一个会话或一个工作组;
  2. // 调度器到点往同一个会话发出指令(开始一轮),或以系统消息发到工作组(按工作组的规则交给机器人)
  3. import { and, desc, eq, inArray, isNotNull, lte } from 'drizzle-orm'
  4. import type { AgentEvent, ScheduleDto, ScheduleOutcome, ScheduleOverviewDto, ScheduleRunDto, ScheduleSpec, ScheduleTarget } from '@agent-server/shared'
  5. import type { AppLogger } from '../app'
  6. import type { DB } from '../db'
  7. import { runs, scheduleRuns, schedules, sessions, teams } from '../db/schema'
  8. import { badRequest, conflict, notFound } from '../lib/errors'
  9. import { newId, nowIso } from '../lib/ids'
  10. import type { GatewayRunContext } from '../mcp-gateway/tokens'
  11. import type { ProfileService } from '../services/catalog'
  12. import type { EventStore } from '../services/event-store'
  13. import type { RunManager } from '../services/runs'
  14. import { isTeamSource, type SessionService } from '../services/sessions'
  15. import type { SettingsService } from '../services/settings'
  16. import type { BotService } from '../teams/bots'
  17. import type { TeamService } from '../teams/service'
  18. import { describeSpec, formatLocal, nextRun, parseSpec, upcoming, type ScheduleInput } from './spec'
  19. import { scheduleTools } from './tools'
  20. type ScheduleRow = typeof schedules.$inferSelect
  21. const TICK_MS = 30_000
  22. export class ScheduleService {
  23. /** 会话中正在执行的定时任务:runId → 执行记录 */
  24. private running = new Map<string, { scheduleId: string; recordId: number; timer: NodeJS.Timeout; timedOut: boolean }>()
  25. private timer: NodeJS.Timeout
  26. constructor(private deps: {
  27. db: DB; log: AppLogger; settings: SettingsService; sessions: SessionService; runs: RunManager; events: EventStore
  28. teams: TeamService; bots: BotService; profiles: ProfileService
  29. }) {
  30. // 服务重启时还在执行的记录:Run 已被标记为中断
  31. deps.db.update(scheduleRuns).set({ outcome: 'failed', note: '服务重启,本次执行中断' }).where(eq(scheduleRuns.outcome, 'running')).run()
  32. // 工具的说明中写明时区(设置修改后的新 Run 生效)
  33. deps.runs.gatewayToolProviders.push(() => scheduleTools(deps.settings.get().schedules.timezone, (name, args, ctx) => this.tool(name, args, ctx)))
  34. deps.events.subscribe('*', e => this.onEvent(e))
  35. this.timer = setInterval(() => void this.tick().catch(err => deps.log.error({ err }, 'schedule tick failed')), TICK_MS)
  36. this.timer.unref()
  37. }
  38. stop() {
  39. clearInterval(this.timer)
  40. }
  41. // ---- 查询与管理 ----
  42. list(target: ScheduleTarget): ScheduleDto[] {
  43. const where = target.kind === 'session' ? eq(schedules.sessionId, target.sessionId) : eq(schedules.teamId, target.teamId)
  44. return this.deps.db.select().from(schedules).where(where).orderBy(schedules.createdAt).all().map(r => this.toDto(r))
  45. }
  46. /** 后台总览 */
  47. overview(): ScheduleOverviewDto[] {
  48. const rows = this.deps.db.select().from(schedules).orderBy(desc(schedules.updatedAt)).all()
  49. const sessionIds = rows.flatMap(r => (r.sessionId ? [r.sessionId] : []))
  50. const teamIds = rows.flatMap(r => (r.teamId ? [r.teamId] : []))
  51. const sessionMap = new Map(sessionIds.length ? this.deps.db.select({ id: sessions.id, title: sessions.title, source: sessions.source }).from(sessions).where(inArray(sessions.id, sessionIds)).all().map(s => [s.id, s]) : [])
  52. const teamMap = new Map(teamIds.length ? this.deps.db.select({ id: teams.id, title: teams.title, source: teams.source }).from(teams).where(inArray(teams.id, teamIds)).all().map(t => [t.id, t]) : [])
  53. return rows.map(r => {
  54. const t = r.sessionId ? sessionMap.get(r.sessionId) : teamMap.get(r.teamId!)
  55. return { ...this.toDto(r), targetTitle: t?.title || '(已删除)', source: t?.source ?? '' }
  56. })
  57. }
  58. get(id: string): ScheduleRow {
  59. const row = this.deps.db.select().from(schedules).where(eq(schedules.id, id)).get()
  60. if (!row) throw notFound('定时任务')
  61. return row
  62. }
  63. /** 平台访问:只能操作自己的会话或工作组的定时任务 */
  64. getFor(id: string, check: (target: ScheduleTarget) => void): ScheduleRow {
  65. const row = this.get(id)
  66. check(this.targetOf(row))
  67. return row
  68. }
  69. runs(id: string, limit = 50): ScheduleRunDto[] {
  70. return this.deps.db.select().from(scheduleRuns).where(eq(scheduleRuns.scheduleId, id)).orderBy(desc(scheduleRuns.id)).limit(limit).all()
  71. .map(r => ({ id: r.id, scheduleId: r.scheduleId, ts: r.ts, outcome: r.outcome, runId: r.runId, costUsd: r.costUsd, note: r.note }))
  72. }
  73. /** 暂停或恢复;恢复时从现在起计算下次执行时间 */
  74. setStatus(id: string, status: 'active' | 'paused'): ScheduleDto {
  75. const row = this.get(id)
  76. if (row.status === 'ended') throw conflict('schedule_ended', '定时任务已结束')
  77. const next = status === 'active' ? nextRun(row.spec, row.timezone, Date.now()) : null
  78. if (status === 'active' && next === null) throw conflict('schedule_ended', '执行时间已经过去')
  79. this.patch(id, { status, statusNote: null, ...(status === 'active' ? { nextRunAt: new Date(next!).toISOString() } : {}) })
  80. return this.toDto(this.get(id))
  81. }
  82. delete(id: string) {
  83. this.get(id)
  84. this.deps.db.delete(schedules).where(eq(schedules.id, id)).run()
  85. this.deps.db.delete(scheduleRuns).where(eq(scheduleRuns.scheduleId, id)).run()
  86. }
  87. /** 会话或工作组删除时一起删除 */
  88. deleteFor(target: ScheduleTarget) {
  89. for (const s of this.list(target)) this.delete(s.id)
  90. }
  91. // ---- 调度 ----
  92. /** 执行到期的定时任务;停机期间错过的只补跑一次 */
  93. async tick(now = Date.now()) {
  94. const due = this.deps.db.select().from(schedules)
  95. .where(and(eq(schedules.status, 'active'), isNotNull(schedules.nextRunAt), lte(schedules.nextRunAt, new Date(now).toISOString()))).all()
  96. for (const row of due) {
  97. try {
  98. this.execute(row, now)
  99. } catch (err) {
  100. this.deps.log.error({ err, scheduleId: row.id }, 'schedule execution failed')
  101. }
  102. }
  103. }
  104. private execute(row: ScheduleRow, now: number) {
  105. const next = nextRun(row.spec, row.timezone, now)
  106. const text = `【定时任务「${row.title}」· ${formatLocal(now, row.timezone)}】\n${row.prompt}`
  107. let outcome: ScheduleOutcome
  108. let note: string | null = null
  109. let runId: string | null = null
  110. if (row.sessionId) {
  111. const session = this.deps.db.select().from(sessions).where(eq(sessions.id, row.sessionId)).get()
  112. if (!session) {
  113. this.delete(row.id)
  114. return
  115. }
  116. try {
  117. runId = this.deps.runs.start(session, text, []).id
  118. outcome = 'running'
  119. } catch (err) {
  120. const busy = (err as { code?: string }).code === 'session_busy'
  121. outcome = busy ? 'skipped' : 'failed'
  122. note = busy ? '会话正在执行,跳过本次' : (err as Error).message
  123. }
  124. } else {
  125. const team = this.deps.db.select().from(teams).where(eq(teams.id, row.teamId!)).get()
  126. if (!team) {
  127. this.delete(row.id)
  128. return
  129. }
  130. if (team.status !== 'active') {
  131. outcome = 'skipped'
  132. note = team.status === 'paused' ? '工作组已暂停,跳过本次' : '工作组已关闭,跳过本次'
  133. } else if (this.deps.teams.isBusy(team.id)) {
  134. outcome = 'skipped'
  135. note = '工作组还有进行中的任务或机器人在工作,跳过本次'
  136. } else {
  137. try {
  138. this.deps.teams.postScheduled(team.id, text, row.createdByBotId ? [row.createdByBotId] : [])
  139. outcome = 'sent'
  140. } catch (err) {
  141. outcome = 'failed'
  142. note = (err as Error).message
  143. }
  144. }
  145. }
  146. const record = this.deps.db.insert(scheduleRuns).values({ scheduleId: row.id, ts: new Date(now).toISOString(), outcome, runId, note }).returning({ id: scheduleRuns.id }).get()
  147. this.patch(row.id, {
  148. lastRunAt: new Date(now).toISOString(), lastOutcome: outcome, runCount: row.runCount + 1,
  149. nextRunAt: next === null ? null : new Date(next).toISOString(),
  150. ...(next === null ? { status: 'ended' as const } : {}),
  151. })
  152. if (runId) {
  153. const minutes = this.deps.settings.get().schedules.maxMinutesPerRun
  154. const entry = { scheduleId: row.id, recordId: record.id, timedOut: false, timer: setTimeout(() => {
  155. entry.timedOut = true
  156. this.deps.runs.interrupt(runId!, row.sessionId!)
  157. }, minutes * 60_000) }
  158. entry.timer.unref()
  159. this.running.set(runId, entry)
  160. }
  161. }
  162. /** 会话中的执行结束:记录结果和成本;成本超出上限时暂停 */
  163. private onEvent(e: AgentEvent) {
  164. if (e.type !== 'run.completed' && e.type !== 'run.failed' && e.type !== 'run.interrupted') return
  165. const entry = e.runId ? this.running.get(e.runId) : undefined
  166. if (!entry) return
  167. this.running.delete(e.runId!)
  168. clearTimeout(entry.timer)
  169. const cost = this.deps.db.select({ costUsd: runs.costUsd }).from(runs).where(eq(runs.id, e.runId!)).get()?.costUsd ?? null
  170. const outcome: ScheduleOutcome = e.type === 'run.completed' ? 'completed' : 'failed'
  171. const minutes = this.deps.settings.get().schedules.maxMinutesPerRun
  172. const note = entry.timedOut ? `超过 ${minutes} 分钟,已中断` : e.type === 'run.failed' ? e.payload.message : e.type === 'run.interrupted' ? '执行被中断' : null
  173. this.deps.db.update(scheduleRuns).set({ outcome, costUsd: cost, note }).where(eq(scheduleRuns.id, entry.recordId)).run()
  174. const row = this.deps.db.select().from(schedules).where(eq(schedules.id, entry.scheduleId)).get()
  175. if (!row) return
  176. const max = this.deps.settings.get().schedules.maxCostUsdPerRun
  177. const over = max !== null && cost !== null && cost > max
  178. this.patch(row.id, {
  179. lastOutcome: outcome,
  180. ...(over && row.status === 'active' ? { status: 'paused' as const, statusNote: `本次执行花费 $${cost!.toFixed(2)},超过每次的上限 $${max},已自动暂停` } : {}),
  181. })
  182. }
  183. // ---- 工具(AI 在对话中调用) ----
  184. private async tool(name: string, args: Record<string, unknown>, ctx: GatewayRunContext): Promise<string> {
  185. const target = this.targetOfSession(ctx.sessionId)
  186. const createdBy = target.kind === 'team' ? this.deps.teams.teamOfSession(ctx.sessionId)?.botId ?? null : null
  187. const s = this.deps.settings.get().schedules
  188. switch (name) {
  189. case 'schedule_list': {
  190. const list = this.list(target)
  191. const now = `当前时间:${formatLocal(Date.now(), s.timezone)}(${s.timezone})`
  192. return `${now}\n\n${list.length ? list.map(x => this.summary(x)).join('\n\n') : '当前没有定时任务。'}`
  193. }
  194. case 'schedule_create': {
  195. if (this.list(target).filter(x => x.status !== 'ended').length >= s.maxPerTarget) throw new Error(`最多 ${s.maxPerTarget} 个定时任务,请先删除不需要的`)
  196. const title = String(args.title ?? '').trim()
  197. const prompt = String(args.prompt ?? '').trim()
  198. const spec = parseSpec(args.schedule as ScheduleInput, { minIntervalHours: s.minIntervalHours, timezone: s.timezone })
  199. const ok = await this.confirm(ctx, target, 'schedule_create', `创建定时任务「${title}」`, title, prompt, spec, s.timezone)
  200. if (!ok.allowed) return `用户没有确认,定时任务没有创建${ok.reason ? `:${ok.reason}` : ''}。`
  201. const next = nextRun(spec, s.timezone, Date.now())
  202. const id = newId('sch')
  203. this.deps.db.insert(schedules).values({
  204. id, title, prompt, spec, timezone: s.timezone, nextRunAt: next === null ? null : new Date(next).toISOString(),
  205. createdByBotId: createdBy,
  206. ...(target.kind === 'session' ? { sessionId: target.sessionId } : { teamId: target.teamId }),
  207. }).run()
  208. return `已创建定时任务(id: ${id}):${describeSpec(spec)},下次执行 ${next === null ? '—' : formatLocal(next, s.timezone)}。`
  209. }
  210. case 'schedule_update': {
  211. const row = this.ownRow(String(args.schedule_id), target)
  212. const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : row.title
  213. const prompt = typeof args.prompt === 'string' && args.prompt.trim() ? args.prompt.trim() : row.prompt
  214. const spec = args.schedule ? parseSpec(args.schedule as ScheduleInput, { minIntervalHours: s.minIntervalHours, timezone: row.timezone }) : row.spec
  215. const changed = title !== row.title || prompt !== row.prompt || JSON.stringify(spec) !== JSON.stringify(row.spec)
  216. if (changed) {
  217. if (row.status === 'ended') throw new Error('定时任务已结束,请重新创建')
  218. const ok = await this.confirm(ctx, target, 'schedule_update', `修改定时任务「${row.title}」`, title, prompt, spec, row.timezone)
  219. if (!ok.allowed) return `用户没有确认,定时任务没有修改${ok.reason ? `:${ok.reason}` : ''}。`
  220. const next = nextRun(spec, row.timezone, Date.now())
  221. this.patch(row.id, { title, prompt, spec, nextRunAt: next === null ? null : new Date(next).toISOString() })
  222. }
  223. if (args.status === 'active' || args.status === 'paused') this.setStatus(row.id, args.status)
  224. return `已更新:\n${this.summary(this.toDto(this.get(row.id)))}`
  225. }
  226. case 'schedule_delete': {
  227. const row = this.ownRow(String(args.schedule_id), target)
  228. this.delete(row.id)
  229. return `已删除定时任务「${row.title}」。`
  230. }
  231. }
  232. throw new Error(`未知的工具:${name}`)
  233. }
  234. /** 确认卡片:时间表、接下来的执行时间、指令;无人值守时会遇到审批的给出提示 */
  235. private async confirm(ctx: GatewayRunContext, target: ScheduleTarget, tool: string, heading: string, title: string, prompt: string, spec: ScheduleSpec, timezone: string) {
  236. if (!title) throw new Error('请填写 title')
  237. if (!prompt) throw new Error('请填写 prompt:到点时要执行的完整指令')
  238. const next = upcoming(spec, timezone, Date.now())
  239. const body = [
  240. `时间:${describeSpec(spec)}(${timezone})`,
  241. `接下来:${next.length ? next.map(t => formatLocal(t, timezone)).join('、') : '—'}`,
  242. '',
  243. `到点时发出的指令:\n${prompt}`,
  244. ].join('\n')
  245. const outcome = await ctx.interact({
  246. kind: 'approval', tool, title: `${heading}:${describeSpec(spec)}`, input: { title, prompt, schedule: spec },
  247. detail: { command: body, ...(this.needsApproval(target) ? { reason: `无人值守时,需要审批的操作会在 ${Math.round(this.deps.settings.get().limits.interactionTimeoutSec / 60)} 分钟后被拒绝` } : {}) },
  248. options: ['allow_once', 'deny'],
  249. })
  250. return { allowed: outcome.resolution === 'allowed', reason: outcome.resolution === 'denied' ? outcome.reason : undefined }
  251. }
  252. /** 执行时可能要人审批:会话的 Profile(或工作组中任一机器人的 Profile)为"需审批" */
  253. private needsApproval(target: ScheduleTarget): boolean {
  254. if (target.kind === 'session') {
  255. const session = this.deps.sessions.getRow(target.sessionId)
  256. return this.deps.sessions.effectiveProfile(session).permissionMode === 'ask'
  257. }
  258. return this.deps.teams.toDto(this.deps.teams.getRow(target.teamId)).bots.some(b => {
  259. try {
  260. return this.deps.profiles.get(this.deps.bots.get(b.id).profileId).permissionMode === 'ask'
  261. } catch {
  262. return false
  263. }
  264. })
  265. }
  266. private ownRow(id: string, target: ScheduleTarget): ScheduleRow {
  267. const row = this.deps.db.select().from(schedules).where(eq(schedules.id, id)).get()
  268. const t = row && this.targetOf(row)
  269. const same = t && (t.kind === 'session' ? target.kind === 'session' && t.sessionId === target.sessionId : target.kind === 'team' && t.teamId === target.teamId)
  270. if (!row || !same) throw new Error('定时任务不存在,请先调用 schedule_list 查看')
  271. return row
  272. }
  273. private summary(x: ScheduleDto): string {
  274. const status = { active: '进行中', paused: `已暂停${x.statusNote ? `(${x.statusNote})` : ''}`, ended: '已结束' }[x.status]
  275. const next = x.nextRunAt && x.status === 'active' ? `,下次 ${formatLocal(Date.parse(x.nextRunAt), x.timezone)}` : ''
  276. return `- ${x.title}(id: ${x.id}):${x.description} · ${status}${next} · 已执行 ${x.runCount} 次\n 指令:${x.prompt}`
  277. }
  278. // ---- 内部 ----
  279. private targetOfSession(sessionId: string): ScheduleTarget {
  280. const session = this.deps.sessions.getRow(sessionId)
  281. if (isTeamSource(session.source)) {
  282. const ref = this.deps.teams.teamOfSession(sessionId)
  283. if (!ref) throw badRequest('这个会话不属于任何工作组')
  284. return { kind: 'team', teamId: ref.teamId }
  285. }
  286. return { kind: 'session', sessionId }
  287. }
  288. private targetOf(r: ScheduleRow): ScheduleTarget {
  289. return r.sessionId ? { kind: 'session', sessionId: r.sessionId } : { kind: 'team', teamId: r.teamId! }
  290. }
  291. private patch(id: string, set: Partial<ScheduleRow>) {
  292. this.deps.db.update(schedules).set({ ...set, updatedAt: nowIso() }).where(eq(schedules.id, id)).run()
  293. }
  294. private toDto(r: ScheduleRow): ScheduleDto {
  295. return {
  296. id: r.id, target: this.targetOf(r), title: r.title, prompt: r.prompt, spec: r.spec, timezone: r.timezone, description: describeSpec(r.spec),
  297. status: r.status, statusNote: r.statusNote, nextRunAt: r.status === 'active' ? r.nextRunAt : null, lastRunAt: r.lastRunAt, lastOutcome: r.lastOutcome,
  298. runCount: r.runCount, createdByBotId: r.createdByBotId, createdAt: r.createdAt, updatedAt: r.updatedAt,
  299. }
  300. }
  301. }