vellum 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +161 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/app-bundler.test.ts +12 -33
  11. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  12. package/src/__tests__/asset-search-tool.test.ts +23 -22
  13. package/src/__tests__/attachments-store.test.ts +56 -127
  14. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  15. package/src/__tests__/browser-skill-endstate.test.ts +5 -8
  16. package/src/__tests__/call-bridge.test.ts +385 -0
  17. package/src/__tests__/call-constants.test.ts +40 -0
  18. package/src/__tests__/call-orchestrator.test.ts +454 -0
  19. package/src/__tests__/call-recovery.test.ts +518 -0
  20. package/src/__tests__/call-routes-http.test.ts +459 -0
  21. package/src/__tests__/call-state-machine.test.ts +143 -0
  22. package/src/__tests__/call-state.test.ts +133 -0
  23. package/src/__tests__/call-store.test.ts +691 -0
  24. package/src/__tests__/cli-discover.test.ts +1 -1
  25. package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
  26. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  27. package/src/__tests__/computer-use-tools.test.ts +250 -0
  28. package/src/__tests__/config-schema.test.ts +348 -3
  29. package/src/__tests__/conflict-store.test.ts +2 -1
  30. package/src/__tests__/contacts-tools.test.ts +331 -0
  31. package/src/__tests__/conversation-store.test.ts +30 -32
  32. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  33. package/src/__tests__/date-context.test.ts +373 -0
  34. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  35. package/src/__tests__/doordash-session.test.ts +9 -0
  36. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  37. package/src/__tests__/followup-tools.test.ts +303 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  39. package/src/__tests__/intent-routing.test.ts +64 -57
  40. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  41. package/src/__tests__/ipc-snapshot.test.ts +96 -28
  42. package/src/__tests__/llm-usage-store.test.ts +3 -8
  43. package/src/__tests__/media-generate-image.test.ts +1 -1
  44. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  45. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  46. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  47. package/src/__tests__/playbook-tools.test.ts +342 -0
  48. package/src/__tests__/profile-compiler.test.ts +2 -1
  49. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  50. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  51. package/src/__tests__/recurrence-engine.test.ts +69 -0
  52. package/src/__tests__/recurrence-types.test.ts +71 -0
  53. package/src/__tests__/registry.test.ts +17 -10
  54. package/src/__tests__/relay-server.test.ts +633 -0
  55. package/src/__tests__/reminder-store.test.ts +6 -3
  56. package/src/__tests__/reminder.test.ts +43 -77
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +222 -0
  58. package/src/__tests__/run-orchestrator.test.ts +7 -7
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +19 -20
  60. package/src/__tests__/runtime-runs-http.test.ts +5 -23
  61. package/src/__tests__/runtime-runs.test.ts +11 -11
  62. package/src/__tests__/schedule-store.test.ts +482 -0
  63. package/src/__tests__/schedule-tools.test.ts +700 -0
  64. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  65. package/src/__tests__/server-history-render.test.ts +14 -13
  66. package/src/__tests__/session-error.test.ts +28 -0
  67. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  68. package/src/__tests__/session-queue.test.ts +89 -16
  69. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  70. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  71. package/src/__tests__/signup-e2e.test.ts +2 -1
  72. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  73. package/src/__tests__/skill-script-runner.test.ts +159 -0
  74. package/src/__tests__/speaker-identification.test.ts +52 -0
  75. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  76. package/src/__tests__/subagent-tools.test.ts +141 -41
  77. package/src/__tests__/task-compiler.test.ts +2 -1
  78. package/src/__tests__/task-runner.test.ts +2 -1
  79. package/src/__tests__/task-scheduler.test.ts +2 -1
  80. package/src/__tests__/task-tools.test.ts +49 -56
  81. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  82. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  84. package/src/__tests__/tool-executor.test.ts +13 -17
  85. package/src/__tests__/turn-commit.test.ts +273 -2
  86. package/src/__tests__/twilio-provider.test.ts +143 -0
  87. package/src/__tests__/twilio-routes.test.ts +789 -0
  88. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  89. package/src/__tests__/view-image-tool.test.ts +217 -0
  90. package/src/__tests__/workspace-git-service.test.ts +403 -0
  91. package/src/__tests__/workspace-heartbeat-service.test.ts +141 -2
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  93. package/src/bundler/app-bundler.ts +35 -14
  94. package/src/calls/call-bridge.ts +95 -0
  95. package/src/calls/call-constants.ts +48 -0
  96. package/src/calls/call-domain.ts +276 -0
  97. package/src/calls/call-orchestrator.ts +390 -0
  98. package/src/calls/call-recovery.ts +207 -0
  99. package/src/calls/call-state-machine.ts +68 -0
  100. package/src/calls/call-state.ts +64 -0
  101. package/src/calls/call-store.ts +416 -0
  102. package/src/calls/relay-server.ts +335 -0
  103. package/src/calls/speaker-identification.ts +213 -0
  104. package/src/calls/twilio-config.ts +34 -0
  105. package/src/calls/twilio-provider.ts +173 -0
  106. package/src/calls/twilio-routes.ts +250 -0
  107. package/src/calls/types.ts +37 -0
  108. package/src/calls/voice-provider.ts +14 -0
  109. package/src/cli/config-commands.ts +334 -0
  110. package/src/cli/core-commands.ts +776 -0
  111. package/src/cli/doordash.ts +256 -25
  112. package/src/cli/ipc-client.ts +82 -0
  113. package/src/cli/map.ts +246 -0
  114. package/src/cli/twitter.ts +575 -0
  115. package/src/cli.ts +7 -5
  116. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  117. package/src/commands/cc-command-registry.ts +209 -0
  118. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  119. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  120. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  123. package/src/config/bundled-skills/document/SKILL.md +18 -0
  124. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  125. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  126. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  127. package/src/config/bundled-skills/doordash/SKILL.md +163 -0
  128. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  129. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  130. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  131. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  133. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -24
  135. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  136. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  137. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  138. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  139. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  140. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  141. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  142. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  143. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  144. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  145. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  146. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  149. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  150. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  151. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  152. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  153. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  154. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  155. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  156. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  157. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  158. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  159. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  160. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  161. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  162. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  163. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  164. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  165. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  166. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  167. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  168. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  169. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  170. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  171. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  172. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  173. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  174. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  175. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  176. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  177. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  178. package/src/config/defaults.ts +44 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +218 -1
  181. package/src/config/system-prompt.ts +100 -6
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +5 -0
  184. package/src/contacts/contact-store.ts +4 -4
  185. package/src/daemon/assistant-attachments.ts +10 -0
  186. package/src/daemon/classifier.ts +3 -1
  187. package/src/daemon/computer-use-session.ts +3 -1
  188. package/src/daemon/date-context.ts +136 -0
  189. package/src/daemon/handlers/apps.ts +16 -1
  190. package/src/daemon/handlers/browser.ts +54 -0
  191. package/src/daemon/handlers/computer-use.ts +7 -1
  192. package/src/daemon/handlers/config.ts +192 -4
  193. package/src/daemon/handlers/diagnostics.ts +5 -1
  194. package/src/daemon/handlers/documents.ts +18 -29
  195. package/src/daemon/handlers/home-base.ts +5 -1
  196. package/src/daemon/handlers/index.ts +40 -271
  197. package/src/daemon/handlers/misc.ts +9 -1
  198. package/src/daemon/handlers/publish.ts +6 -1
  199. package/src/daemon/handlers/sessions.ts +65 -12
  200. package/src/daemon/handlers/shared.ts +36 -1
  201. package/src/daemon/handlers/signing.ts +37 -0
  202. package/src/daemon/handlers/skills.ts +20 -6
  203. package/src/daemon/handlers/subagents.ts +8 -3
  204. package/src/daemon/handlers/twitter-auth.ts +169 -0
  205. package/src/daemon/handlers/work-items.ts +495 -39
  206. package/src/daemon/ipc-contract-inventory.json +40 -4
  207. package/src/daemon/ipc-contract.ts +185 -37
  208. package/src/daemon/ipc-protocol.ts +7 -2
  209. package/src/daemon/lifecycle.ts +48 -5
  210. package/src/daemon/main.ts +10 -4
  211. package/src/daemon/ride-shotgun-handler.ts +74 -10
  212. package/src/daemon/server.ts +144 -29
  213. package/src/daemon/session-agent-loop.ts +887 -0
  214. package/src/daemon/session-attachments.ts +28 -5
  215. package/src/daemon/session-error.ts +24 -3
  216. package/src/daemon/session-lifecycle.ts +147 -0
  217. package/src/daemon/session-media-retry.ts +147 -0
  218. package/src/daemon/session-messaging.ts +145 -0
  219. package/src/daemon/session-notifiers.ts +164 -0
  220. package/src/daemon/session-process.ts +2 -2
  221. package/src/daemon/session-queue-manager.ts +1 -0
  222. package/src/daemon/session-runtime-assembly.ts +52 -0
  223. package/src/daemon/session-skill-tools.ts +124 -5
  224. package/src/daemon/session-slash.ts +3 -0
  225. package/src/daemon/session-surfaces.ts +77 -2
  226. package/src/daemon/session-tool-setup.ts +222 -2
  227. package/src/daemon/session-usage.ts +0 -2
  228. package/src/daemon/session.ts +114 -1365
  229. package/src/daemon/video-thumbnail.ts +60 -0
  230. package/src/doordash/client.ts +121 -27
  231. package/src/doordash/queries.ts +1 -2
  232. package/src/export/formatter.ts +3 -1
  233. package/src/followups/followup-store.ts +4 -2
  234. package/src/followups/types.ts +6 -0
  235. package/src/hooks/templates.ts +1 -1
  236. package/src/index.ts +32 -1151
  237. package/src/media/gemini-image-service.ts +1 -1
  238. package/src/memory/attachments-store.ts +28 -83
  239. package/src/memory/channel-delivery-store.ts +7 -21
  240. package/src/memory/clarification-resolver.ts +6 -5
  241. package/src/memory/contradiction-checker.ts +3 -2
  242. package/src/memory/conversation-key-store.ts +10 -29
  243. package/src/memory/conversation-store.ts +2 -1
  244. package/src/memory/db.ts +362 -2
  245. package/src/memory/entity-extractor.ts +6 -3
  246. package/src/memory/items-extractor.ts +5 -4
  247. package/src/memory/jobs-store.ts +3 -2
  248. package/src/memory/llm-usage-store.ts +1 -2
  249. package/src/memory/runs-store.ts +1 -2
  250. package/src/memory/schema.ts +65 -2
  251. package/src/messaging/style-analyzer.ts +3 -2
  252. package/src/messaging/thread-summarizer.ts +8 -12
  253. package/src/messaging/triage-engine.ts +4 -2
  254. package/src/providers/openrouter/client.ts +20 -0
  255. package/src/providers/registry.ts +8 -0
  256. package/src/runtime/http-server.ts +277 -25
  257. package/src/runtime/http-types.ts +0 -2
  258. package/src/runtime/routes/attachment-routes.ts +5 -6
  259. package/src/runtime/routes/call-routes.ts +140 -0
  260. package/src/runtime/routes/channel-routes.ts +12 -19
  261. package/src/runtime/routes/conversation-routes.ts +5 -9
  262. package/src/runtime/routes/run-routes.ts +4 -8
  263. package/src/runtime/run-orchestrator.ts +39 -6
  264. package/src/schedule/recurrence-engine.ts +138 -0
  265. package/src/schedule/recurrence-types.ts +67 -0
  266. package/src/schedule/schedule-store.ts +102 -57
  267. package/src/schedule/scheduler.ts +9 -6
  268. package/src/security/oauth2.ts +29 -4
  269. package/src/security/secret-allowlist.ts +46 -0
  270. package/src/skills/clawhub.ts +1 -1
  271. package/src/subagent/manager.ts +40 -8
  272. package/src/swarm/backend-claude-code.ts +64 -9
  273. package/src/swarm/worker-prompts.ts +2 -1
  274. package/src/tasks/SPEC.md +34 -28
  275. package/src/tasks/ephemeral-permissions.ts +16 -7
  276. package/src/tasks/task-compiler.ts +5 -4
  277. package/src/tasks/task-runner.ts +10 -5
  278. package/src/tasks/task-scheduler.ts +1 -1
  279. package/src/tasks/tool-sanitizer.ts +36 -0
  280. package/src/tools/assets/search.ts +4 -4
  281. package/src/tools/browser/api-map.ts +220 -0
  282. package/src/tools/browser/auto-navigate.ts +270 -0
  283. package/src/tools/browser/browser-execution.ts +2 -1
  284. package/src/tools/browser/browser-manager.ts +2 -2
  285. package/src/tools/browser/network-recorder.ts +5 -4
  286. package/src/tools/browser/x-auto-navigate.ts +207 -0
  287. package/src/tools/calls/call-end.ts +67 -0
  288. package/src/tools/calls/call-start.ts +73 -0
  289. package/src/tools/calls/call-status.ts +81 -0
  290. package/src/tools/claude-code/claude-code.ts +77 -11
  291. package/src/tools/contacts/contact-merge.ts +46 -78
  292. package/src/tools/contacts/contact-search.ts +35 -79
  293. package/src/tools/contacts/contact-upsert.ts +35 -108
  294. package/src/tools/credentials/vault.ts +21 -5
  295. package/src/tools/document/document-tool.ts +71 -144
  296. package/src/tools/executor.ts +129 -10
  297. package/src/tools/followups/followup_create.ts +46 -88
  298. package/src/tools/followups/followup_list.ts +34 -74
  299. package/src/tools/followups/followup_resolve.ts +31 -66
  300. package/src/tools/host-terminal/cli-discover.ts +2 -1
  301. package/src/tools/host-terminal/host-shell.ts +10 -0
  302. package/src/tools/memory/handlers.ts +5 -4
  303. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  304. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  305. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  306. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  307. package/src/tools/network/web-fetch.ts +18 -6
  308. package/src/tools/playbooks/index.ts +4 -5
  309. package/src/tools/playbooks/playbook-create.ts +3 -47
  310. package/src/tools/playbooks/playbook-delete.ts +1 -25
  311. package/src/tools/playbooks/playbook-list.ts +1 -28
  312. package/src/tools/playbooks/playbook-update.ts +3 -51
  313. package/src/tools/registry.ts +2 -4
  314. package/src/tools/reminder/reminder.ts +5 -78
  315. package/src/tools/schedule/create.ts +69 -74
  316. package/src/tools/schedule/delete.ts +21 -47
  317. package/src/tools/schedule/list.ts +55 -74
  318. package/src/tools/schedule/update.ts +77 -84
  319. package/src/tools/subagent/abort.ts +29 -58
  320. package/src/tools/subagent/message.ts +30 -63
  321. package/src/tools/subagent/read.ts +53 -84
  322. package/src/tools/subagent/spawn.ts +43 -82
  323. package/src/tools/subagent/status.ts +42 -71
  324. package/src/tools/swarm/delegate.ts +2 -1
  325. package/src/tools/tasks/index.ts +8 -6
  326. package/src/tools/tasks/task-delete.ts +69 -56
  327. package/src/tools/tasks/task-list.ts +31 -52
  328. package/src/tools/tasks/task-run.ts +74 -102
  329. package/src/tools/tasks/task-save.ts +33 -65
  330. package/src/tools/tasks/work-item-enqueue.ts +192 -134
  331. package/src/tools/tasks/work-item-list.ts +33 -78
  332. package/src/tools/tasks/work-item-remove.ts +60 -0
  333. package/src/tools/tasks/work-item-update.ts +114 -0
  334. package/src/tools/terminal/backends/native.ts +3 -1
  335. package/src/tools/tool-manifest.ts +20 -74
  336. package/src/tools/types.ts +6 -0
  337. package/src/tools/ui-surface/definitions.ts +6 -1
  338. package/src/tools/watch/screen-watch.ts +3 -1
  339. package/src/tools/watcher/create.ts +52 -98
  340. package/src/tools/watcher/delete.ts +20 -46
  341. package/src/tools/watcher/digest.ts +36 -70
  342. package/src/tools/watcher/list.ts +49 -79
  343. package/src/tools/watcher/update.ts +45 -91
  344. package/src/twitter/client.ts +690 -0
  345. package/src/twitter/session.ts +91 -0
  346. package/src/usage/types.ts +0 -1
  347. package/src/util/truncate.ts +6 -0
  348. package/src/watcher/providers/slack.ts +2 -1
  349. package/src/watcher/watcher-store.ts +3 -2
  350. package/src/work-items/work-item-store.ts +236 -2
  351. package/src/workspace/commit-message-enrichment-service.ts +284 -0
  352. package/src/workspace/commit-message-provider.ts +95 -0
  353. package/src/workspace/git-service.ts +272 -52
  354. package/src/workspace/heartbeat-service.ts +70 -13
  355. package/src/workspace/provider-commit-message-generator.ts +242 -0
  356. package/src/workspace/turn-commit.ts +100 -51
  357. package/src/tools/contacts/index.ts +0 -4
  358. package/src/tools/document/index.ts +0 -5
  359. package/src/tools/followups/index.ts +0 -3
  360. package/src/tools/subagent/index.ts +0 -5
  361. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -0,0 +1,138 @@
1
+ import { Cron } from 'croner';
2
+ import { rrulestr, RRuleSet } from 'rrule';
3
+ import type { ScheduleSyntax } from './recurrence-types.js';
4
+
5
+ export interface ScheduleSpec {
6
+ syntax: ScheduleSyntax;
7
+ expression: string;
8
+ timezone?: string | null;
9
+ }
10
+
11
+ const SUPPORTED_RRULE_PREFIXES = ['DTSTART', 'RRULE:', 'RDATE', 'EXDATE', 'EXRULE'];
12
+
13
+ function normalizeRruleExpression(expression: string): string {
14
+ // Handle escaped newlines from JSON transport
15
+ return expression.replace(/\\n/g, '\n').trim();
16
+ }
17
+
18
+ function parseRruleLines(expression: string): string[] {
19
+ return normalizeRruleExpression(expression)
20
+ .split(/\r?\n/)
21
+ .map(l => l.trim())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ function validateRruleLines(lines: string[]): string | null {
26
+ let hasInclusion = false;
27
+ let hasDtstart = false;
28
+
29
+ for (const line of lines) {
30
+ const upper = line.toUpperCase();
31
+ if (!SUPPORTED_RRULE_PREFIXES.some(p => upper.startsWith(p))) {
32
+ return `Unsupported recurrence line: ${line}`;
33
+ }
34
+ if (upper.startsWith('DTSTART')) hasDtstart = true;
35
+ if (upper.startsWith('RRULE:') || upper.startsWith('RDATE')) hasInclusion = true;
36
+ }
37
+
38
+ if (!hasDtstart) return 'RRULE expression must include DTSTART for deterministic scheduling';
39
+ if (!hasInclusion) return 'RRULE expression must include at least one RRULE or RDATE';
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Detect whether an RRULE expression contains set constructs (RDATE, EXDATE,
45
+ * EXRULE, or multiple RRULE lines) that require RRuleSet parsing.
46
+ */
47
+ export function hasSetConstructs(expression: string): boolean {
48
+ const lines = parseRruleLines(expression);
49
+ let rruleCount = 0;
50
+ for (const line of lines) {
51
+ const upper = line.toUpperCase();
52
+ if (upper.startsWith('RDATE') || upper.startsWith('EXDATE') || upper.startsWith('EXRULE')) return true;
53
+ if (upper.startsWith('RRULE:')) rruleCount++;
54
+ }
55
+ return rruleCount > 1;
56
+ }
57
+
58
+ /**
59
+ * Validate RRULE set lines in an expression. Returns null if valid, or an
60
+ * actionable error string describing the problem. This is intended for tool
61
+ * layers that want to surface a specific error message before calling the
62
+ * store.
63
+ */
64
+ export function validateRruleSetLines(expression: string): string | null {
65
+ const lines = parseRruleLines(expression);
66
+ return validateRruleLines(lines);
67
+ }
68
+
69
+ /**
70
+ * Validate a schedule expression. Returns true if the expression is valid
71
+ * for the given syntax, false otherwise.
72
+ */
73
+ export function isValidScheduleExpression(spec: ScheduleSpec): boolean {
74
+ try {
75
+ if (spec.syntax === 'cron') {
76
+ new Cron(spec.expression, { maxRuns: 0 });
77
+ return true;
78
+ }
79
+
80
+ if (spec.syntax === 'rrule') {
81
+ const lines = parseRruleLines(spec.expression);
82
+ const error = validateRruleLines(lines);
83
+ if (error) return false;
84
+
85
+ const normalized = normalizeRruleExpression(spec.expression);
86
+ const tzid = spec.timezone ?? undefined;
87
+ if (hasSetConstructs(normalized)) {
88
+ rrulestr(normalized, { forceset: true, tzid });
89
+ } else {
90
+ rrulestr(normalized, { tzid });
91
+ }
92
+ return true;
93
+ }
94
+
95
+ return false;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Compute the next run timestamp (epoch ms) for a schedule expression.
103
+ * Throws if no future runs exist.
104
+ */
105
+ export function computeNextRunAt(spec: ScheduleSpec, nowMs?: number): number {
106
+ const now = nowMs ?? Date.now();
107
+
108
+ if (spec.syntax === 'cron') {
109
+ const cron = new Cron(spec.expression, {
110
+ timezone: spec.timezone ?? undefined,
111
+ });
112
+ const next = cron.nextRun(new Date(now));
113
+ if (!next) {
114
+ throw new Error(`Cron expression "${spec.expression}" has no upcoming runs`);
115
+ }
116
+ return next.getTime();
117
+ }
118
+
119
+ if (spec.syntax === 'rrule') {
120
+ const normalized = normalizeRruleExpression(spec.expression);
121
+ const lines = parseRruleLines(normalized);
122
+ const error = validateRruleLines(lines);
123
+ if (error) throw new Error(error);
124
+
125
+ const useSet = hasSetConstructs(normalized);
126
+ const tzid = spec.timezone ?? undefined;
127
+ const parsed = useSet
128
+ ? (rrulestr(normalized, { forceset: true, tzid }) as RRuleSet)
129
+ : rrulestr(normalized, { tzid });
130
+ const next = parsed.after(new Date(now));
131
+ if (!next) {
132
+ throw new Error(`RRULE expression has no upcoming runs after ${new Date(now).toISOString()}`);
133
+ }
134
+ return next.getTime();
135
+ }
136
+
137
+ throw new Error(`Unsupported schedule syntax: ${spec.syntax}`);
138
+ }
@@ -0,0 +1,67 @@
1
+ export type ScheduleSyntax = 'cron' | 'rrule';
2
+
3
+ /**
4
+ * Detect whether an expression string is cron or RRULE syntax.
5
+ * Returns null for ambiguous or invalid expressions.
6
+ */
7
+ export function detectScheduleSyntax(expression: string): ScheduleSyntax | null {
8
+ if (!expression || typeof expression !== 'string') return null;
9
+ const trimmed = expression.trim();
10
+ if (!trimmed) return null;
11
+
12
+ // RRULE detection: starts with RRULE:, DTSTART, or contains FREQ=
13
+ if (/^(RRULE:|DTSTART)/m.test(trimmed) || /FREQ=/i.test(trimmed)) {
14
+ return 'rrule';
15
+ }
16
+
17
+ // Cron detection: 5 space-separated fields
18
+ const fields = trimmed.split(/\s+/);
19
+ if (fields.length === 5) {
20
+ // Basic sanity check: each field should match cron-like characters
21
+ const cronFieldPattern = /^[\d\*\/\-\,\?LW#]+$/;
22
+ if (fields.every(f => cronFieldPattern.test(f))) {
23
+ return 'cron';
24
+ }
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Normalize schedule syntax from tool/API inputs.
32
+ * Resolution order:
33
+ * 1. If explicit `syntax` is provided, use it
34
+ * 2. If `expression` is provided, auto-detect from expression
35
+ * 3. If `legacyCronExpression` is provided, treat as cron
36
+ * 4. Return null if nothing resolved
37
+ */
38
+ export function normalizeScheduleSyntax(input: {
39
+ syntax?: ScheduleSyntax;
40
+ expression?: string;
41
+ legacyCronExpression?: string;
42
+ }): { syntax: ScheduleSyntax; expression: string } | null {
43
+ // Explicit syntax + expression
44
+ if (input.syntax && input.expression) {
45
+ return { syntax: input.syntax, expression: input.expression };
46
+ }
47
+
48
+ // Auto-detect from expression
49
+ if (input.expression) {
50
+ const detected = detectScheduleSyntax(input.expression);
51
+ if (detected) {
52
+ return { syntax: detected, expression: input.expression };
53
+ }
54
+ // If we have an explicit syntax but couldn't detect, trust the explicit syntax
55
+ if (input.syntax) {
56
+ return { syntax: input.syntax, expression: input.expression };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Legacy cron_expression fallback
62
+ if (input.legacyCronExpression) {
63
+ return { syntax: 'cron', expression: input.legacyCronExpression };
64
+ }
65
+
66
+ return null;
67
+ }
@@ -2,12 +2,16 @@ import { and, asc, desc, eq, lte } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
  import { Cron } from 'croner';
4
4
  import { getDb } from '../memory/db.js';
5
- import { cronJobs, cronRuns } from '../memory/schema.js';
5
+ import { scheduleJobs, scheduleRuns } from '../memory/schema.js';
6
+ import { computeNextRunAt as computeNextRunAtEngine, isValidScheduleExpression } from './recurrence-engine.js';
7
+ import type { ScheduleSyntax } from './recurrence-types.js';
6
8
 
7
9
  export interface ScheduleJob {
8
10
  id: string;
9
11
  name: string;
10
12
  enabled: boolean;
13
+ syntax: ScheduleSyntax;
14
+ expression: string;
11
15
  cronExpression: string;
12
16
  timezone: string | null;
13
17
  message: string;
@@ -48,7 +52,7 @@ export function computeNextRunAt(cronExpression: string, timezone?: string | nul
48
52
  });
49
53
  const next = cron.nextRun();
50
54
  if (!next) {
51
- throw new Error(`Cron expression "${cronExpression}" has no upcoming runs`);
55
+ throw new Error(`Schedule expression "${cronExpression}" has no upcoming runs`);
52
56
  }
53
57
  return next.getTime();
54
58
  }
@@ -60,9 +64,16 @@ export function createSchedule(params: {
60
64
  message: string;
61
65
  enabled?: boolean;
62
66
  createdBy?: string;
67
+ syntax?: ScheduleSyntax;
68
+ expression?: string;
63
69
  }): ScheduleJob {
64
- if (!isValidCronExpression(params.cronExpression)) {
65
- throw new Error(`Invalid cron expression: "${params.cronExpression}"`);
70
+ // Resolve syntax and expression: prefer explicit values, fall back to cron default
71
+ const syntax: ScheduleSyntax = params.syntax ?? 'cron';
72
+ const expression = params.expression ?? params.cronExpression;
73
+
74
+ const spec = { syntax, expression, timezone: params.timezone };
75
+ if (!isValidScheduleExpression(spec)) {
76
+ throw new Error(`Invalid ${syntax} expression: "${expression}"`);
66
77
  }
67
78
 
68
79
  const db = getDb();
@@ -70,13 +81,14 @@ export function createSchedule(params: {
70
81
  const now = Date.now();
71
82
  const enabled = params.enabled ?? true;
72
83
  const timezone = params.timezone ?? null;
73
- const nextRunAt = enabled ? computeNextRunAt(params.cronExpression, timezone) : 0;
84
+ const nextRunAt = enabled ? computeNextRunAtEngine(spec) : 0;
74
85
 
75
86
  const row = {
76
87
  id,
77
88
  name: params.name,
78
89
  enabled,
79
- cronExpression: params.cronExpression,
90
+ cronExpression: expression,
91
+ scheduleSyntax: syntax,
80
92
  timezone,
81
93
  message: params.message,
82
94
  nextRunAt,
@@ -88,16 +100,16 @@ export function createSchedule(params: {
88
100
  updatedAt: now,
89
101
  };
90
102
 
91
- db.insert(cronJobs).values(row).run();
92
- return row;
103
+ db.insert(scheduleJobs).values(row).run();
104
+ return parseJobRow(row);
93
105
  }
94
106
 
95
107
  export function getSchedule(id: string): ScheduleJob | null {
96
108
  const db = getDb();
97
109
  const row = db
98
110
  .select()
99
- .from(cronJobs)
100
- .where(eq(cronJobs.id, id))
111
+ .from(scheduleJobs)
112
+ .where(eq(scheduleJobs.id, id))
101
113
  .get();
102
114
  if (!row) return null;
103
115
  return parseJobRow(row);
@@ -105,12 +117,12 @@ export function getSchedule(id: string): ScheduleJob | null {
105
117
 
106
118
  export function listSchedules(options?: { enabledOnly?: boolean }): ScheduleJob[] {
107
119
  const db = getDb();
108
- const conditions = options?.enabledOnly ? eq(cronJobs.enabled, true) : undefined;
120
+ const conditions = options?.enabledOnly ? eq(scheduleJobs.enabled, true) : undefined;
109
121
  const rows = db
110
122
  .select()
111
- .from(cronJobs)
123
+ .from(scheduleJobs)
112
124
  .where(conditions)
113
- .orderBy(asc(cronJobs.nextRunAt))
125
+ .orderBy(asc(scheduleJobs.nextRunAt))
114
126
  .all();
115
127
  return rows.map(parseJobRow);
116
128
  }
@@ -123,90 +135,120 @@ export function updateSchedule(
123
135
  timezone?: string | null;
124
136
  message?: string;
125
137
  enabled?: boolean;
138
+ syntax?: ScheduleSyntax;
139
+ expression?: string;
126
140
  },
127
141
  ): ScheduleJob | null {
128
142
  const db = getDb();
129
- const existing = db.select().from(cronJobs).where(eq(cronJobs.id, id)).get();
143
+ const existing = db.select().from(scheduleJobs).where(eq(scheduleJobs.id, id)).get();
130
144
  if (!existing) return null;
131
145
 
132
- if (updates.cronExpression && !isValidCronExpression(updates.cronExpression)) {
133
- throw new Error(`Invalid cron expression: "${updates.cronExpression}"`);
146
+ // Resolve the effective syntax and expression after this update
147
+ const newSyntax = updates.syntax ?? (existing.scheduleSyntax as ScheduleSyntax) ?? 'cron';
148
+ const newExpr = updates.expression ?? updates.cronExpression ?? existing.cronExpression;
149
+ const newTimezone = updates.timezone !== undefined ? updates.timezone : existing.timezone;
150
+ const newEnabled = updates.enabled !== undefined ? updates.enabled : existing.enabled;
151
+
152
+ // Validate if expression or syntax changed
153
+ if (updates.expression !== undefined || updates.cronExpression !== undefined || updates.syntax !== undefined) {
154
+ const spec = { syntax: newSyntax, expression: newExpr, timezone: newTimezone };
155
+ if (!isValidScheduleExpression(spec)) {
156
+ throw new Error(`Invalid ${newSyntax} expression: "${newExpr}"`);
157
+ }
134
158
  }
135
159
 
136
160
  const now = Date.now();
137
161
  const set: Record<string, unknown> = { updatedAt: now };
138
162
 
139
163
  if (updates.name !== undefined) set.name = updates.name;
140
- if (updates.cronExpression !== undefined) set.cronExpression = updates.cronExpression;
164
+ if (updates.cronExpression !== undefined || updates.expression !== undefined) set.cronExpression = newExpr;
165
+ if (updates.syntax !== undefined) set.scheduleSyntax = newSyntax;
141
166
  if (updates.timezone !== undefined) set.timezone = updates.timezone;
142
167
  if (updates.message !== undefined) set.message = updates.message;
143
168
  if (updates.enabled !== undefined) set.enabled = updates.enabled;
144
169
 
145
- const newExpr = updates.cronExpression ?? existing.cronExpression;
146
- const newTimezone = updates.timezone !== undefined ? updates.timezone : existing.timezone;
147
- const newEnabled = updates.enabled !== undefined ? updates.enabled : existing.enabled;
148
-
170
+ // Recompute nextRunAt if schedule timing may have changed
149
171
  if (
150
172
  updates.cronExpression !== undefined ||
173
+ updates.expression !== undefined ||
174
+ updates.syntax !== undefined ||
151
175
  updates.timezone !== undefined ||
152
176
  updates.enabled !== undefined
153
177
  ) {
154
- set.nextRunAt = newEnabled ? computeNextRunAt(newExpr, newTimezone) : 0;
178
+ const spec = { syntax: newSyntax, expression: newExpr, timezone: newTimezone };
179
+ set.nextRunAt = newEnabled ? computeNextRunAtEngine(spec) : 0;
155
180
  }
156
181
 
157
- db.update(cronJobs).set(set).where(eq(cronJobs.id, id)).run();
182
+ db.update(scheduleJobs).set(set).where(eq(scheduleJobs.id, id)).run();
158
183
 
159
184
  return getSchedule(id);
160
185
  }
161
186
 
162
187
  export function deleteSchedule(id: string): boolean {
163
188
  const db = getDb();
164
- const result = db.delete(cronJobs).where(eq(cronJobs.id, id)).run() as unknown as { changes?: number };
189
+ const result = db.delete(scheduleJobs).where(eq(scheduleJobs.id, id)).run() as unknown as { changes?: number };
165
190
  return (result.changes ?? 0) > 0;
166
191
  }
167
192
 
168
193
  /**
169
- * Claim due schedules atomically. For each candidate where enabled=true and
170
- * next_run_at <= now, we advance next_run_at using optimistic locking on the
171
- * old value to prevent double-claiming by concurrent ticks.
194
+ * Claim due recurrence schedules atomically. For each candidate where
195
+ * enabled=true and next_run_at <= now, we advance next_run_at using
196
+ * optimistic locking on the old value to prevent double-claiming by
197
+ * concurrent ticks. Works for both cron and RRULE syntax.
172
198
  */
173
199
  export function claimDueSchedules(now: number): ScheduleJob[] {
174
200
  const db = getDb();
175
201
  const candidates = db
176
202
  .select()
177
- .from(cronJobs)
178
- .where(and(eq(cronJobs.enabled, true), lte(cronJobs.nextRunAt, now)))
179
- .orderBy(asc(cronJobs.nextRunAt))
203
+ .from(scheduleJobs)
204
+ .where(and(eq(scheduleJobs.enabled, true), lte(scheduleJobs.nextRunAt, now)))
205
+ .orderBy(asc(scheduleJobs.nextRunAt))
180
206
  .all();
181
207
 
182
208
  const claimed: ScheduleJob[] = [];
183
209
  for (const row of candidates) {
184
- let newNextRunAt: number;
210
+ let newNextRunAt: number | null;
211
+ let exhausted = false;
185
212
  try {
186
- newNextRunAt = computeNextRunAt(row.cronExpression, row.timezone);
213
+ const syntax = (row.scheduleSyntax as ScheduleSyntax) ?? 'cron';
214
+ newNextRunAt = computeNextRunAtEngine({
215
+ syntax,
216
+ expression: row.cronExpression,
217
+ timezone: row.timezone,
218
+ });
187
219
  } catch {
188
- // Expression has no future runs — skip
189
- continue;
220
+ // Finite schedule with no future runs — still claim the current due
221
+ // run but disable the schedule so it doesn't fire again.
222
+ newNextRunAt = null;
223
+ exhausted = true;
190
224
  }
191
225
 
192
226
  // Optimistic lock: only update if nextRunAt hasn't changed
227
+ const updates: Record<string, unknown> = {
228
+ lastRunAt: now,
229
+ updatedAt: now,
230
+ };
231
+ if (exhausted) {
232
+ updates.nextRunAt = 0;
233
+ updates.enabled = false;
234
+ } else {
235
+ updates.nextRunAt = newNextRunAt!;
236
+ }
237
+
193
238
  const result = db
194
- .update(cronJobs)
195
- .set({
196
- nextRunAt: newNextRunAt,
197
- lastRunAt: now,
198
- updatedAt: now,
199
- })
200
- .where(and(eq(cronJobs.id, row.id), eq(cronJobs.nextRunAt, row.nextRunAt)))
239
+ .update(scheduleJobs)
240
+ .set(updates)
241
+ .where(and(eq(scheduleJobs.id, row.id), eq(scheduleJobs.nextRunAt, row.nextRunAt)))
201
242
  .run() as unknown as { changes?: number };
202
243
 
203
244
  if ((result.changes ?? 0) === 0) continue;
204
245
 
205
246
  claimed.push(parseJobRow({
206
247
  ...row,
207
- nextRunAt: newNextRunAt,
248
+ nextRunAt: exhausted ? 0 : newNextRunAt!,
208
249
  lastRunAt: now,
209
250
  updatedAt: now,
251
+ enabled: exhausted ? false : row.enabled,
210
252
  }));
211
253
  }
212
254
  return claimed;
@@ -216,7 +258,7 @@ export function createScheduleRun(jobId: string, conversationId: string): string
216
258
  const db = getDb();
217
259
  const id = uuid();
218
260
  const now = Date.now();
219
- db.insert(cronRuns).values({
261
+ db.insert(scheduleRuns).values({
220
262
  id,
221
263
  jobId,
222
264
  status: 'running',
@@ -238,12 +280,12 @@ export function completeScheduleRun(
238
280
  const db = getDb();
239
281
  const now = Date.now();
240
282
 
241
- const run = db.select().from(cronRuns).where(eq(cronRuns.id, runId)).get();
283
+ const run = db.select().from(scheduleRuns).where(eq(scheduleRuns.id, runId)).get();
242
284
  if (!run) return;
243
285
 
244
286
  const durationMs = now - run.startedAt;
245
287
 
246
- db.update(cronRuns)
288
+ db.update(scheduleRuns)
247
289
  .set({
248
290
  status: result.status,
249
291
  finishedAt: now,
@@ -251,23 +293,23 @@ export function completeScheduleRun(
251
293
  output: result.output?.slice(0, 10_000) ?? null,
252
294
  error: result.error?.slice(0, 2000) ?? null,
253
295
  })
254
- .where(eq(cronRuns.id, runId))
296
+ .where(eq(scheduleRuns.id, runId))
255
297
  .run();
256
298
 
257
299
  // Update the parent job's lastStatus and retryCount
258
300
  if (result.status === 'error') {
259
301
  // Increment retry count
260
- const job = db.select().from(cronJobs).where(eq(cronJobs.id, run.jobId)).get();
302
+ const job = db.select().from(scheduleJobs).where(eq(scheduleJobs.id, run.jobId)).get();
261
303
  if (job) {
262
- db.update(cronJobs)
304
+ db.update(scheduleJobs)
263
305
  .set({ lastStatus: 'error', retryCount: job.retryCount + 1, updatedAt: now })
264
- .where(eq(cronJobs.id, run.jobId))
306
+ .where(eq(scheduleJobs.id, run.jobId))
265
307
  .run();
266
308
  }
267
309
  } else {
268
- db.update(cronJobs)
310
+ db.update(scheduleJobs)
269
311
  .set({ lastStatus: 'ok', retryCount: 0, updatedAt: now })
270
- .where(eq(cronJobs.id, run.jobId))
312
+ .where(eq(scheduleJobs.id, run.jobId))
271
313
  .run();
272
314
  }
273
315
  }
@@ -276,9 +318,9 @@ export function getScheduleRuns(jobId: string, limit?: number): ScheduleRun[] {
276
318
  const db = getDb();
277
319
  const rows = db
278
320
  .select()
279
- .from(cronRuns)
280
- .where(eq(cronRuns.jobId, jobId))
281
- .orderBy(desc(cronRuns.createdAt))
321
+ .from(scheduleRuns)
322
+ .where(eq(scheduleRuns.jobId, jobId))
323
+ .orderBy(desc(scheduleRuns.createdAt))
282
324
  .limit(limit ?? 10)
283
325
  .all();
284
326
  return rows.map(parseRunRow);
@@ -296,7 +338,8 @@ export function formatLocalDate(timestamp: number): string {
296
338
  }
297
339
 
298
340
  // Convert a cron expression to a human-readable description.
299
- // Uses the croner library to parse the expression and inspect its pattern fields.
341
+ // Only applicable to cron syntax; RRULE schedules should display the
342
+ // raw expression text instead.
300
343
  //
301
344
  // Examples:
302
345
  // "* * * * *" -> "Every minute"
@@ -418,11 +461,13 @@ export function describeCronExpression(expr: string): string {
418
461
  }
419
462
  }
420
463
 
421
- function parseJobRow(row: typeof cronJobs.$inferSelect): ScheduleJob {
464
+ function parseJobRow(row: typeof scheduleJobs.$inferSelect): ScheduleJob {
422
465
  return {
423
466
  id: row.id,
424
467
  name: row.name,
425
468
  enabled: row.enabled,
469
+ syntax: (row.scheduleSyntax as ScheduleSyntax) ?? 'cron',
470
+ expression: row.cronExpression,
426
471
  cronExpression: row.cronExpression,
427
472
  timezone: row.timezone,
428
473
  message: row.message,
@@ -436,7 +481,7 @@ function parseJobRow(row: typeof cronJobs.$inferSelect): ScheduleJob {
436
481
  };
437
482
  }
438
483
 
439
- function parseRunRow(row: typeof cronRuns.$inferSelect): ScheduleRun {
484
+ function parseRunRow(row: typeof scheduleRuns.$inferSelect): ScheduleRun {
440
485
  return {
441
486
  id: row.id,
442
487
  jobId: row.jobId,
@@ -5,6 +5,7 @@ import {
5
5
  createScheduleRun,
6
6
  completeScheduleRun,
7
7
  } from './schedule-store.js';
8
+ import { hasSetConstructs } from './recurrence-engine.js';
8
9
  import { claimDueReminders, completeReminder, failReminder, setReminderConversationId } from '../tools/reminder/reminder-store.js';
9
10
  import { runWatchersOnce, type WatcherNotifier, type WatcherEscalator } from '../watcher/engine.js';
10
11
 
@@ -73,19 +74,20 @@ async function runScheduleOnce(
73
74
  const now = Date.now();
74
75
  let processed = 0;
75
76
 
76
- // ── Cron jobs ───────────────────────────────────────────────────────
77
+ // ── Recurrence schedules (cron + RRULE) ─────────────────────────────
77
78
  const jobs = claimDueSchedules(now);
78
79
  for (const job of jobs) {
79
80
  // Check if message is a task invocation (run_task:<task_id>)
80
81
  const taskMatch = job.message.match(/^run_task:(\S+)$/);
81
82
  if (taskMatch) {
82
83
  const taskId = taskMatch[1];
84
+ const isRruleSet = job.syntax === 'rrule' && hasSetConstructs(job.expression);
83
85
  try {
84
- log.info({ jobId: job.id, name: job.name, taskId }, 'Executing scheduled task');
86
+ log.info({ jobId: job.id, name: job.name, taskId, syntax: job.syntax, expression: job.expression, isRruleSet }, 'Executing scheduled task');
85
87
  const { runTask } = await import('../tasks/task-runner.js');
86
88
  const result = await runTask(
87
89
  { taskId, workingDir: process.cwd() },
88
- processMessage as (conversationId: string, message: string) => Promise<void>,
90
+ processMessage as (conversationId: string, message: string, taskRunId: string) => Promise<void>,
89
91
  );
90
92
 
91
93
  // Track the schedule run using the task's conversation
@@ -99,7 +101,7 @@ async function runScheduleOnce(
99
101
  processed += 1;
100
102
  } catch (err) {
101
103
  const message = err instanceof Error ? err.message : String(err);
102
- log.warn({ err, jobId: job.id, name: job.name, taskId }, 'Scheduled task execution failed');
104
+ log.warn({ err, jobId: job.id, name: job.name, taskId, syntax: job.syntax, expression: job.expression, isRruleSet }, 'Scheduled task execution failed');
103
105
  // Create a fallback conversation for the schedule run record
104
106
  const fallbackConversation = createConversation(`Schedule: ${job.name}`);
105
107
  const runId = createScheduleRun(job.id, fallbackConversation.id);
@@ -110,16 +112,17 @@ async function runScheduleOnce(
110
112
 
111
113
  const conversation = createConversation(`Schedule: ${job.name}`);
112
114
  const runId = createScheduleRun(job.id, conversation.id);
115
+ const isRruleSetMsg = job.syntax === 'rrule' && hasSetConstructs(job.expression);
113
116
 
114
117
  try {
115
- log.info({ jobId: job.id, name: job.name, conversationId: conversation.id }, 'Executing schedule');
118
+ log.info({ jobId: job.id, name: job.name, syntax: job.syntax, expression: job.expression, isRruleSet: isRruleSetMsg, conversationId: conversation.id }, 'Executing schedule');
116
119
  await processMessage(conversation.id, job.message);
117
120
  completeScheduleRun(runId, { status: 'ok' });
118
121
  notifySchedule({ id: job.id, name: job.name });
119
122
  processed += 1;
120
123
  } catch (err) {
121
124
  const message = err instanceof Error ? err.message : String(err);
122
- log.warn({ err, jobId: job.id, name: job.name }, 'Schedule execution failed');
125
+ log.warn({ err, jobId: job.id, name: job.name, syntax: job.syntax, expression: job.expression, isRruleSet: isRruleSetMsg }, 'Schedule execution failed');
123
126
  completeScheduleRun(runId, { status: 'error', error: message });
124
127
  }
125
128
  }
@@ -6,6 +6,9 @@
6
6
  */
7
7
 
8
8
  import { randomBytes, createHash } from 'node:crypto';
9
+ import { getLogger } from '../util/logger.js';
10
+
11
+ const log = getLogger('oauth2');
9
12
 
10
13
  // ---------------------------------------------------------------------------
11
14
  // Types
@@ -170,8 +173,19 @@ export async function startOAuth2Flow(
170
173
  });
171
174
 
172
175
  if (!tokenResp.ok) {
173
- const body = await tokenResp.text().catch(() => '');
174
- throw new Error(`OAuth2 token exchange failed (${tokenResp.status}): ${body}`);
176
+ const rawBody = await tokenResp.text().catch(() => '');
177
+ let safeDetail: Record<string, unknown> = {};
178
+ let errorCode = '';
179
+ try {
180
+ const parsed = JSON.parse(rawBody) as Record<string, unknown>;
181
+ if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); }
182
+ if (parsed.error_description) safeDetail.error_description = String(parsed.error_description);
183
+ } catch {
184
+ safeDetail.error = '[non-JSON response]';
185
+ }
186
+ log.error({ status: tokenResp.status, ...safeDetail }, 'OAuth2 token exchange failed');
187
+ const detail = errorCode ? `HTTP ${tokenResp.status}: ${errorCode}` : `HTTP ${tokenResp.status}`;
188
+ throw new Error(`OAuth2 token exchange failed (${detail})`);
175
189
  }
176
190
 
177
191
  const tokenData = await tokenResp.json() as Record<string, unknown>;
@@ -225,8 +239,19 @@ export async function refreshOAuth2Token(
225
239
  });
226
240
 
227
241
  if (!resp.ok) {
228
- const body = await resp.text().catch(() => '');
229
- throw new Error(`OAuth2 token refresh failed (${resp.status}): ${body}`);
242
+ const rawBody = await resp.text().catch(() => '');
243
+ let safeDetail: Record<string, unknown> = {};
244
+ let errorCode = '';
245
+ try {
246
+ const parsed = JSON.parse(rawBody) as Record<string, unknown>;
247
+ if (parsed.error) { safeDetail.error = String(parsed.error); errorCode = String(parsed.error); }
248
+ if (parsed.error_description) safeDetail.error_description = String(parsed.error_description);
249
+ } catch {
250
+ safeDetail.error = '[non-JSON response]';
251
+ }
252
+ log.error({ status: resp.status, ...safeDetail }, 'OAuth2 token refresh failed');
253
+ const detail = errorCode ? `HTTP ${resp.status}: ${errorCode}` : `HTTP ${resp.status}`;
254
+ throw new Error(`OAuth2 token refresh failed (${detail})`);
230
255
  }
231
256
 
232
257
  const data = await resp.json() as Record<string, unknown>;