@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,425 @@
1
+ // Scheduled task runner for executing due `send --send-at` jobs in the bot process.
2
+
3
+ import { REST, Routes } from 'discord.js'
4
+ import yaml from 'js-yaml'
5
+ import {
6
+ claimScheduledTaskRunning,
7
+ getDuePlannedScheduledTasks,
8
+ markScheduledTaskCronRescheduled,
9
+ markScheduledTaskCronRetry,
10
+ markScheduledTaskFailed,
11
+ markScheduledTaskOneShotCompleted,
12
+ recoverStaleRunningScheduledTasks,
13
+ type ScheduledTask,
14
+ } from './database.js'
15
+ import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js'
16
+ import { notifyError } from './sentry.js'
17
+ import type { ThreadStartMarker } from './system-message.js'
18
+ import {
19
+ getLocalTimeZone,
20
+ getNextCronRun,
21
+ getPromptPreview,
22
+ parseScheduledTaskPayload,
23
+ } from './task-schedule.js'
24
+ import { createDiscordRest } from './discord-api.js'
25
+
26
+ const taskLogger = createLogger(LogPrefix.TASK)
27
+
28
+ type StartTaskRunnerOptions = {
29
+ token: string
30
+ pollIntervalMs?: number
31
+ staleRunningMs?: number
32
+ dueBatchSize?: number
33
+ }
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === 'object' && value !== null
37
+ }
38
+
39
+ function parseMessageId(value: unknown): string | Error {
40
+ if (!isRecord(value)) {
41
+ return new Error('Discord response is not an object')
42
+ }
43
+ if (typeof value.id !== 'string') {
44
+ return new Error('Discord response is missing message ID')
45
+ }
46
+ return value.id
47
+ }
48
+
49
+ async function executeThreadScheduledTask({
50
+ rest,
51
+ task,
52
+ payload,
53
+ }: {
54
+ rest: REST
55
+ task: ScheduledTask
56
+ payload: {
57
+ threadId: string
58
+ prompt: string
59
+ agent: string | null
60
+ model: string | null
61
+ username: string | null
62
+ userId: string | null
63
+ }
64
+ }): Promise<void | Error> {
65
+ const marker: ThreadStartMarker = {
66
+ cliThreadPrompt: true,
67
+ scheduledKind: task.schedule_kind,
68
+ scheduledTaskId: task.id,
69
+ ...(payload.agent ? { agent: payload.agent } : {}),
70
+ ...(payload.model ? { model: payload.model } : {}),
71
+ ...(payload.username ? { username: payload.username } : {}),
72
+ ...(payload.userId ? { userId: payload.userId } : {}),
73
+ }
74
+ const embed = [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }]
75
+ const prefixedPrompt = `» **kimaki-cli:** ${payload.prompt}`
76
+
77
+ const postResult = await rest
78
+ .post(Routes.channelMessages(payload.threadId), {
79
+ body: {
80
+ content: prefixedPrompt,
81
+ embeds: embed,
82
+ },
83
+ })
84
+ .catch((error) => {
85
+ return new Error(`Failed to post scheduled thread task ${task.id}`, {
86
+ cause: error,
87
+ })
88
+ })
89
+
90
+ if (postResult instanceof Error) {
91
+ return postResult
92
+ }
93
+ }
94
+
95
+ async function executeChannelScheduledTask({
96
+ rest,
97
+ task,
98
+ payload,
99
+ }: {
100
+ rest: REST
101
+ task: ScheduledTask
102
+ payload: {
103
+ channelId: string
104
+ prompt: string
105
+ name: string | null
106
+ notifyOnly: boolean
107
+ worktreeName: string | null
108
+ agent: string | null
109
+ model: string | null
110
+ username: string | null
111
+ userId: string | null
112
+ }
113
+ }): Promise<void | Error> {
114
+ const marker: ThreadStartMarker | undefined = payload.notifyOnly
115
+ ? undefined
116
+ : {
117
+ start: true,
118
+ scheduledKind: task.schedule_kind,
119
+ scheduledTaskId: task.id,
120
+ ...(payload.worktreeName ? { worktree: payload.worktreeName } : {}),
121
+ ...(payload.agent ? { agent: payload.agent } : {}),
122
+ ...(payload.model ? { model: payload.model } : {}),
123
+ ...(payload.username ? { username: payload.username } : {}),
124
+ ...(payload.userId ? { userId: payload.userId } : {}),
125
+ }
126
+ const embeds = marker
127
+ ? [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }]
128
+ : undefined
129
+
130
+ const starterResult = await rest
131
+ .post(Routes.channelMessages(payload.channelId), {
132
+ body: {
133
+ content: payload.prompt,
134
+ embeds,
135
+ },
136
+ })
137
+ .catch((error) => {
138
+ return new Error(`Failed to create starter message for task ${task.id}`, {
139
+ cause: error,
140
+ })
141
+ })
142
+
143
+ if (starterResult instanceof Error) {
144
+ return starterResult
145
+ }
146
+
147
+ const starterMessageId = parseMessageId(starterResult)
148
+ if (starterMessageId instanceof Error) {
149
+ return new Error(`Invalid starter message response for task ${task.id}`, {
150
+ cause: starterMessageId,
151
+ })
152
+ }
153
+
154
+ const threadName = (payload.name || getPromptPreview(payload.prompt)).slice(
155
+ 0,
156
+ 100,
157
+ )
158
+ const threadResult = await rest
159
+ .post(Routes.threads(payload.channelId, starterMessageId), {
160
+ body: {
161
+ name: threadName,
162
+ auto_archive_duration: 1440,
163
+ },
164
+ })
165
+ .catch((error) => {
166
+ return new Error(`Failed to create thread for task ${task.id}`, {
167
+ cause: error,
168
+ })
169
+ })
170
+
171
+ if (threadResult instanceof Error) {
172
+ return threadResult
173
+ }
174
+
175
+ if (!payload.userId) {
176
+ return
177
+ }
178
+
179
+ const threadIdResult = parseMessageId(threadResult)
180
+ if (threadIdResult instanceof Error) {
181
+ return new Error(`Invalid thread response for task ${task.id}`, {
182
+ cause: threadIdResult,
183
+ })
184
+ }
185
+
186
+ const addMemberResult = await rest
187
+ .put(Routes.threadMembers(threadIdResult, payload.userId))
188
+ .catch((error) => {
189
+ return new Error(
190
+ `Failed to add user to scheduled thread for task ${task.id}`,
191
+ { cause: error },
192
+ )
193
+ })
194
+ if (addMemberResult instanceof Error) {
195
+ return addMemberResult
196
+ }
197
+ }
198
+
199
+ async function executeScheduledTask({
200
+ rest,
201
+ task,
202
+ }: {
203
+ rest: REST
204
+ task: ScheduledTask
205
+ }): Promise<void | Error> {
206
+ const payloadResult = parseScheduledTaskPayload(task.payload_json)
207
+ if (payloadResult instanceof Error) {
208
+ return new Error(`Task ${task.id} has invalid payload`, {
209
+ cause: payloadResult,
210
+ })
211
+ }
212
+
213
+ if (payloadResult.kind === 'thread') {
214
+ return executeThreadScheduledTask({
215
+ rest,
216
+ task,
217
+ payload: payloadResult,
218
+ })
219
+ }
220
+
221
+ return executeChannelScheduledTask({
222
+ rest,
223
+ task,
224
+ payload: payloadResult,
225
+ })
226
+ }
227
+
228
+ async function finalizeSuccessfulTask({
229
+ task,
230
+ completedAt,
231
+ }: {
232
+ task: ScheduledTask
233
+ completedAt: Date
234
+ }): Promise<void> {
235
+ if (task.schedule_kind === 'at') {
236
+ await markScheduledTaskOneShotCompleted({ taskId: task.id, completedAt })
237
+ return
238
+ }
239
+
240
+ if (!task.cron_expr) {
241
+ await markScheduledTaskFailed({
242
+ taskId: task.id,
243
+ failedAt: completedAt,
244
+ errorMessage: 'Missing cron expression on cron task',
245
+ })
246
+ return
247
+ }
248
+
249
+ const timezone = task.timezone || getLocalTimeZone()
250
+ const nextRunResult = getNextCronRun({
251
+ cronExpr: task.cron_expr,
252
+ timezone,
253
+ from: completedAt,
254
+ })
255
+ if (nextRunResult instanceof Error) {
256
+ await markScheduledTaskFailed({
257
+ taskId: task.id,
258
+ failedAt: completedAt,
259
+ errorMessage: nextRunResult.message,
260
+ })
261
+ return
262
+ }
263
+
264
+ await markScheduledTaskCronRescheduled({
265
+ taskId: task.id,
266
+ completedAt,
267
+ nextRunAt: nextRunResult,
268
+ })
269
+ }
270
+
271
+ async function finalizeFailedTask({
272
+ task,
273
+ failedAt,
274
+ error,
275
+ }: {
276
+ task: ScheduledTask
277
+ failedAt: Date
278
+ error: Error
279
+ }): Promise<void> {
280
+ if (task.schedule_kind === 'cron' && task.cron_expr) {
281
+ const timezone = task.timezone || getLocalTimeZone()
282
+ const nextRunResult = getNextCronRun({
283
+ cronExpr: task.cron_expr,
284
+ timezone,
285
+ from: failedAt,
286
+ })
287
+ if (!(nextRunResult instanceof Error)) {
288
+ await markScheduledTaskCronRetry({
289
+ taskId: task.id,
290
+ failedAt,
291
+ errorMessage: error.message,
292
+ nextRunAt: nextRunResult,
293
+ })
294
+ return
295
+ }
296
+ }
297
+
298
+ await markScheduledTaskFailed({
299
+ taskId: task.id,
300
+ failedAt,
301
+ errorMessage: error.message,
302
+ })
303
+ }
304
+
305
+ async function processDueTask({
306
+ rest,
307
+ task,
308
+ }: {
309
+ rest: REST
310
+ task: ScheduledTask
311
+ }): Promise<void> {
312
+ const startedAt = new Date()
313
+ const claimed = await claimScheduledTaskRunning({
314
+ taskId: task.id,
315
+ startedAt,
316
+ })
317
+ if (!claimed) {
318
+ return
319
+ }
320
+
321
+ const executeResult = await executeScheduledTask({ rest, task })
322
+ const finishedAt = new Date()
323
+
324
+ if (executeResult instanceof Error) {
325
+ taskLogger.warn(
326
+ `[task-runner] task ${task.id} failed: ${formatErrorWithStack(executeResult)}`,
327
+ )
328
+ await finalizeFailedTask({
329
+ task,
330
+ failedAt: finishedAt,
331
+ error: executeResult,
332
+ })
333
+ return
334
+ }
335
+
336
+ await finalizeSuccessfulTask({ task, completedAt: finishedAt })
337
+ }
338
+
339
+ async function runTaskRunnerTick({
340
+ rest,
341
+ staleRunningMs,
342
+ dueBatchSize,
343
+ }: {
344
+ rest: REST
345
+ staleRunningMs: number
346
+ dueBatchSize: number
347
+ }): Promise<void> {
348
+ const staleBefore = new Date(Date.now() - staleRunningMs)
349
+ const recoveredCount = await recoverStaleRunningScheduledTasks({
350
+ staleBefore,
351
+ })
352
+ if (recoveredCount > 0) {
353
+ taskLogger.warn(
354
+ `[task-runner] Recovered ${recoveredCount} stale running task(s)`,
355
+ )
356
+ }
357
+
358
+ const dueTasks = await getDuePlannedScheduledTasks({
359
+ now: new Date(),
360
+ limit: dueBatchSize,
361
+ })
362
+
363
+ await dueTasks.reduce<Promise<void>>(async (previous, task) => {
364
+ await previous
365
+ await processDueTask({ rest, task })
366
+ }, Promise.resolve())
367
+ }
368
+
369
+ export function startTaskRunner({
370
+ token,
371
+ pollIntervalMs = 5_000,
372
+ staleRunningMs = 120_000,
373
+ dueBatchSize = 20,
374
+ }: StartTaskRunnerOptions): () => Promise<void> {
375
+ const rest = createDiscordRest(token)
376
+ let stopped = false
377
+ let ticking = false
378
+ let tickPromise: Promise<void> | null = null
379
+
380
+ const tick = async () => {
381
+ if (stopped || ticking) {
382
+ return
383
+ }
384
+
385
+ ticking = true
386
+ const currentTickPromise = runTaskRunnerTick({
387
+ rest,
388
+ staleRunningMs,
389
+ dueBatchSize,
390
+ }).catch((error) => {
391
+ return new Error('Task runner tick failed', { cause: error })
392
+ })
393
+ tickPromise = currentTickPromise.then(() => {
394
+ return
395
+ })
396
+ const runResult = await currentTickPromise
397
+ if (runResult instanceof Error) {
398
+ taskLogger.error(`[task-runner] ${formatErrorWithStack(runResult)}`)
399
+ void notifyError(runResult, 'Task runner tick failed')
400
+ }
401
+ ticking = false
402
+ tickPromise = null
403
+ }
404
+
405
+ const timer = setInterval(() => {
406
+ void tick()
407
+ }, pollIntervalMs)
408
+
409
+ void tick()
410
+
411
+ taskLogger.log(`[task-runner] started (interval=${pollIntervalMs}ms)`)
412
+
413
+ return async () => {
414
+ if (stopped) {
415
+ return
416
+ }
417
+ stopped = true
418
+ clearInterval(timer)
419
+ if (tickPromise) {
420
+ await tickPromise
421
+ tickPromise = null
422
+ }
423
+ taskLogger.log('[task-runner] stopped')
424
+ }
425
+ }
@@ -0,0 +1,84 @@
1
+ // Tests for scheduled task date/cron parsing and UTC validation rules.
2
+
3
+ import { describe, expect, test } from 'vitest'
4
+ import { parseSendAtValue } from './task-schedule.js'
5
+
6
+ describe('parseSendAtValue', () => {
7
+ test('accepts UTC ISO date ending with Z', () => {
8
+ const now = new Date('2026-02-22T13:00:00Z')
9
+ const result = parseSendAtValue({
10
+ value: '2026-03-01T09:00:00Z',
11
+ now,
12
+ timezone: 'UTC',
13
+ })
14
+
15
+ expect(result).not.toBeInstanceOf(Error)
16
+ if (result instanceof Error) {
17
+ throw result
18
+ }
19
+
20
+ expect(result.scheduleKind).toBe('at')
21
+ expect(result.runAt?.toISOString()).toBe('2026-03-01T09:00:00.000Z')
22
+ expect(result.nextRunAt.toISOString()).toBe('2026-03-01T09:00:00.000Z')
23
+ })
24
+
25
+ test('rejects ISO date with non-UTC offset', () => {
26
+ const now = new Date('2026-02-22T13:00:00Z')
27
+ const result = parseSendAtValue({
28
+ value: '2026-03-01T09:00:00+01:00',
29
+ now,
30
+ timezone: 'UTC',
31
+ })
32
+
33
+ expect(result).toBeInstanceOf(Error)
34
+ if (result instanceof Error) {
35
+ expect(result.message).toContain('must be UTC ISO format ending with Z')
36
+ }
37
+ })
38
+
39
+ test('rejects local ISO date without timezone suffix', () => {
40
+ const now = new Date('2026-02-22T13:00:00Z')
41
+ const result = parseSendAtValue({
42
+ value: '2026-03-01T09:00:00',
43
+ now,
44
+ timezone: 'UTC',
45
+ })
46
+
47
+ expect(result).toBeInstanceOf(Error)
48
+ if (result instanceof Error) {
49
+ expect(result.message).toContain('must be UTC ISO format ending with Z')
50
+ }
51
+ })
52
+
53
+ test('rejects UTC dates in the past', () => {
54
+ const now = new Date('2026-02-22T13:00:00Z')
55
+ const result = parseSendAtValue({
56
+ value: '2026-02-22T12:59:59Z',
57
+ now,
58
+ timezone: 'UTC',
59
+ })
60
+
61
+ expect(result).toBeInstanceOf(Error)
62
+ if (result instanceof Error) {
63
+ expect(result.message).toContain('must be in the future (UTC)')
64
+ }
65
+ })
66
+
67
+ test('accepts cron expressions', () => {
68
+ const now = new Date('2026-02-22T13:00:00Z')
69
+ const result = parseSendAtValue({
70
+ value: '0 9 * * 1',
71
+ now,
72
+ timezone: 'UTC',
73
+ })
74
+
75
+ expect(result).not.toBeInstanceOf(Error)
76
+ if (result instanceof Error) {
77
+ throw result
78
+ }
79
+
80
+ expect(result.scheduleKind).toBe('cron')
81
+ expect(result.cronExpr).toBe('0 9 * * 1')
82
+ expect(result.nextRunAt.toISOString()).toBe('2026-02-23T09:00:00.000Z')
83
+ })
84
+ })