discoclaw 0.1.0

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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,401 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { checkDiscordToken, checkOpenAiKey, checkOpenRouterKey, formatCredentialReport, runCredentialChecks, } from './credential-check.js';
3
+ // Helpers ------------------------------------------------------------------
4
+ function mockFetch(status, body = '') {
5
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status })));
6
+ }
7
+ function mockFetchError(message) {
8
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error(message)));
9
+ }
10
+ function makeReport(results) {
11
+ const criticalFailures = results
12
+ .filter((r) => r.status === 'fail' && r.name === 'discord-token')
13
+ .map((r) => r.name);
14
+ const allOk = results.every((r) => r.status === 'ok' || r.status === 'skip');
15
+ return { results, criticalFailures, allOk };
16
+ }
17
+ // checkDiscordToken --------------------------------------------------------
18
+ describe('checkDiscordToken', () => {
19
+ it('returns ok for a 200 response', async () => {
20
+ mockFetch(200, '{"id":"123","username":"TestBot","bot":true}');
21
+ const result = await checkDiscordToken('valid-token');
22
+ expect(result.name).toBe('discord-token');
23
+ expect(result.status).toBe('ok');
24
+ expect(result.message).toBeUndefined();
25
+ });
26
+ it('returns fail with 401 message for unauthorized', async () => {
27
+ mockFetch(401, '{"message":"401: Unauthorized","code":0}');
28
+ const result = await checkDiscordToken('bad-token');
29
+ expect(result.status).toBe('fail');
30
+ expect(result.message).toContain('401');
31
+ });
32
+ it('returns fail for an unexpected HTTP status', async () => {
33
+ mockFetch(503);
34
+ const result = await checkDiscordToken('token');
35
+ expect(result.status).toBe('fail');
36
+ expect(result.message).toContain('503');
37
+ });
38
+ it('returns fail on network error without throwing', async () => {
39
+ mockFetchError('ECONNREFUSED');
40
+ const result = await checkDiscordToken('token');
41
+ expect(result.status).toBe('fail');
42
+ expect(result.message).toContain('network error');
43
+ expect(result.message).toContain('ECONNREFUSED');
44
+ });
45
+ it('sends request to the correct Discord API endpoint with Bot prefix', async () => {
46
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
47
+ vi.stubGlobal('fetch', fetchSpy);
48
+ await checkDiscordToken('my-token');
49
+ const [url, init] = fetchSpy.mock.calls[0];
50
+ expect(url).toBe('https://discord.com/api/v10/users/@me');
51
+ expect(init.headers['Authorization']).toBe('Bot my-token');
52
+ });
53
+ });
54
+ // checkOpenAiKey -----------------------------------------------------------
55
+ describe('checkOpenAiKey', () => {
56
+ it('returns skip when no API key is provided', async () => {
57
+ const result = await checkOpenAiKey({});
58
+ expect(result.name).toBe('openai-key');
59
+ expect(result.status).toBe('skip');
60
+ });
61
+ it('returns skip for an empty string key', async () => {
62
+ const result = await checkOpenAiKey({ apiKey: '' });
63
+ expect(result.status).toBe('skip');
64
+ });
65
+ it('returns ok for a 200 response', async () => {
66
+ mockFetch(200, '{"object":"list","data":[]}');
67
+ const result = await checkOpenAiKey({ apiKey: 'sk-valid' });
68
+ expect(result.name).toBe('openai-key');
69
+ expect(result.status).toBe('ok');
70
+ expect(result.message).toBeUndefined();
71
+ });
72
+ it('returns fail with 401 message for an invalid key', async () => {
73
+ mockFetch(401, '{"error":{"message":"Incorrect API key"}}');
74
+ const result = await checkOpenAiKey({ apiKey: 'sk-bad' });
75
+ expect(result.status).toBe('fail');
76
+ expect(result.message).toContain('401');
77
+ });
78
+ it('returns fail with 403 message for a key lacking permissions', async () => {
79
+ mockFetch(403, '{"error":{"message":"Forbidden"}}');
80
+ const result = await checkOpenAiKey({ apiKey: 'sk-no-perms' });
81
+ expect(result.status).toBe('fail');
82
+ expect(result.message).toContain('403');
83
+ });
84
+ it('returns fail for an unexpected HTTP status', async () => {
85
+ mockFetch(429, '{"error":{"message":"Rate limit exceeded"}}');
86
+ const result = await checkOpenAiKey({ apiKey: 'sk-test' });
87
+ expect(result.status).toBe('fail');
88
+ expect(result.message).toContain('429');
89
+ });
90
+ it('returns fail on network error without throwing', async () => {
91
+ mockFetchError('connect ETIMEDOUT');
92
+ const result = await checkOpenAiKey({ apiKey: 'sk-test' });
93
+ expect(result.status).toBe('fail');
94
+ expect(result.message).toContain('network error');
95
+ expect(result.message).toContain('ETIMEDOUT');
96
+ });
97
+ it('uses the default OpenAI base URL when none is provided', async () => {
98
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
99
+ vi.stubGlobal('fetch', fetchSpy);
100
+ await checkOpenAiKey({ apiKey: 'sk-test' });
101
+ const [url] = fetchSpy.mock.calls[0];
102
+ expect(url).toBe('https://api.openai.com/v1/models');
103
+ });
104
+ it('uses a custom base URL when provided', async () => {
105
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
106
+ vi.stubGlobal('fetch', fetchSpy);
107
+ await checkOpenAiKey({ apiKey: 'sk-test', baseUrl: 'https://my.proxy.example.com/v1' });
108
+ const [url] = fetchSpy.mock.calls[0];
109
+ expect(url).toBe('https://my.proxy.example.com/v1/models');
110
+ });
111
+ it('strips a trailing slash from the custom base URL', async () => {
112
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
113
+ vi.stubGlobal('fetch', fetchSpy);
114
+ await checkOpenAiKey({ apiKey: 'sk-test', baseUrl: 'https://my.proxy.example.com/v1/' });
115
+ const [url] = fetchSpy.mock.calls[0];
116
+ expect(url).toBe('https://my.proxy.example.com/v1/models');
117
+ });
118
+ it('sends the correct Bearer Authorization header', async () => {
119
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
120
+ vi.stubGlobal('fetch', fetchSpy);
121
+ await checkOpenAiKey({ apiKey: 'sk-my-key' });
122
+ const [, init] = fetchSpy.mock.calls[0];
123
+ expect(init.headers['Authorization']).toBe('Bearer sk-my-key');
124
+ });
125
+ it('does not call fetch when no key is provided', async () => {
126
+ const fetchSpy = vi.fn();
127
+ vi.stubGlobal('fetch', fetchSpy);
128
+ await checkOpenAiKey({});
129
+ expect(fetchSpy).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+ // checkOpenRouterKey -------------------------------------------------------
133
+ describe('checkOpenRouterKey', () => {
134
+ it('returns skip when no API key is provided', async () => {
135
+ const result = await checkOpenRouterKey({});
136
+ expect(result.name).toBe('openrouter-key');
137
+ expect(result.status).toBe('skip');
138
+ });
139
+ it('returns skip for an empty string key', async () => {
140
+ const result = await checkOpenRouterKey({ apiKey: '' });
141
+ expect(result.status).toBe('skip');
142
+ });
143
+ it('returns ok for a 200 response', async () => {
144
+ mockFetch(200, '{"object":"list","data":[]}');
145
+ const result = await checkOpenRouterKey({ apiKey: 'sk-or-valid' });
146
+ expect(result.name).toBe('openrouter-key');
147
+ expect(result.status).toBe('ok');
148
+ expect(result.message).toBeUndefined();
149
+ });
150
+ it('returns fail with 401 message for an invalid key', async () => {
151
+ mockFetch(401, '{"error":{"message":"Invalid API key"}}');
152
+ const result = await checkOpenRouterKey({ apiKey: 'sk-or-bad' });
153
+ expect(result.status).toBe('fail');
154
+ expect(result.message).toContain('401');
155
+ });
156
+ it('returns fail with 403 message for a key lacking permissions', async () => {
157
+ mockFetch(403, '{"error":{"message":"Forbidden"}}');
158
+ const result = await checkOpenRouterKey({ apiKey: 'sk-or-no-perms' });
159
+ expect(result.status).toBe('fail');
160
+ expect(result.message).toContain('403');
161
+ });
162
+ it('returns fail for an unexpected HTTP status', async () => {
163
+ mockFetch(429, '{"error":{"message":"Rate limit exceeded"}}');
164
+ const result = await checkOpenRouterKey({ apiKey: 'sk-or-test' });
165
+ expect(result.status).toBe('fail');
166
+ expect(result.message).toContain('429');
167
+ });
168
+ it('returns fail on network error without throwing', async () => {
169
+ mockFetchError('connect ETIMEDOUT');
170
+ const result = await checkOpenRouterKey({ apiKey: 'sk-or-test' });
171
+ expect(result.status).toBe('fail');
172
+ expect(result.message).toContain('network error');
173
+ expect(result.message).toContain('ETIMEDOUT');
174
+ });
175
+ it('uses the default OpenRouter base URL when none is provided', async () => {
176
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
177
+ vi.stubGlobal('fetch', fetchSpy);
178
+ await checkOpenRouterKey({ apiKey: 'sk-or-test' });
179
+ const [url] = fetchSpy.mock.calls[0];
180
+ expect(url).toBe('https://openrouter.ai/api/v1/models');
181
+ });
182
+ it('uses a custom base URL when provided', async () => {
183
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
184
+ vi.stubGlobal('fetch', fetchSpy);
185
+ await checkOpenRouterKey({ apiKey: 'sk-or-test', baseUrl: 'https://my.proxy.example.com/v1' });
186
+ const [url] = fetchSpy.mock.calls[0];
187
+ expect(url).toBe('https://my.proxy.example.com/v1/models');
188
+ });
189
+ it('strips a trailing slash from the custom base URL', async () => {
190
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
191
+ vi.stubGlobal('fetch', fetchSpy);
192
+ await checkOpenRouterKey({
193
+ apiKey: 'sk-or-test',
194
+ baseUrl: 'https://my.proxy.example.com/v1/',
195
+ });
196
+ const [url] = fetchSpy.mock.calls[0];
197
+ expect(url).toBe('https://my.proxy.example.com/v1/models');
198
+ });
199
+ it('sends the correct Bearer Authorization header', async () => {
200
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
201
+ vi.stubGlobal('fetch', fetchSpy);
202
+ await checkOpenRouterKey({ apiKey: 'sk-or-my-key' });
203
+ const [, init] = fetchSpy.mock.calls[0];
204
+ expect(init.headers['Authorization']).toBe('Bearer sk-or-my-key');
205
+ });
206
+ it('does not call fetch when no key is provided', async () => {
207
+ const fetchSpy = vi.fn();
208
+ vi.stubGlobal('fetch', fetchSpy);
209
+ await checkOpenRouterKey({});
210
+ expect(fetchSpy).not.toHaveBeenCalled();
211
+ });
212
+ });
213
+ // runCredentialChecks ------------------------------------------------------
214
+ describe('runCredentialChecks', () => {
215
+ it('returns four results (one per check)', async () => {
216
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })));
217
+ const report = await runCredentialChecks({ token: 'valid-token', openaiApiKey: 'sk-valid' });
218
+ expect(report.results).toHaveLength(4);
219
+ expect(report.results.map((r) => r.name)).toEqual([
220
+ 'discord-token',
221
+ 'openai-key',
222
+ 'workspace-path',
223
+ 'status-channel',
224
+ ]);
225
+ });
226
+ it('sets allOk=true when all configured checks pass', async () => {
227
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })));
228
+ const report = await runCredentialChecks({ token: 'valid-token', openaiApiKey: 'sk-valid' });
229
+ expect(report.allOk).toBe(true);
230
+ expect(report.criticalFailures).toHaveLength(0);
231
+ });
232
+ it('sets allOk=true when the optional OpenAI key is not provided (skip counts as ok)', async () => {
233
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })));
234
+ const report = await runCredentialChecks({ token: 'valid-token' });
235
+ expect(report.allOk).toBe(true);
236
+ expect(report.results.find((r) => r.name === 'openai-key')?.status).toBe('skip');
237
+ });
238
+ it('sets allOk=false and lists discord-token in criticalFailures when it fails', async () => {
239
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 401 })));
240
+ const report = await runCredentialChecks({ token: 'bad-token' });
241
+ expect(report.allOk).toBe(false);
242
+ expect(report.criticalFailures).toContain('discord-token');
243
+ });
244
+ it('sets allOk=false but no criticalFailures when only openai-key fails', async () => {
245
+ vi.stubGlobal('fetch', vi.fn()
246
+ .mockResolvedValueOnce(new Response('{}', { status: 200 })) // discord: ok
247
+ .mockResolvedValueOnce(new Response('{}', { status: 401 })));
248
+ const report = await runCredentialChecks({ token: 'valid-token', openaiApiKey: 'sk-bad' });
249
+ expect(report.allOk).toBe(false);
250
+ expect(report.criticalFailures).toHaveLength(0);
251
+ });
252
+ it('runs all checks concurrently (both fetch calls are made)', async () => {
253
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
254
+ vi.stubGlobal('fetch', fetchSpy);
255
+ await runCredentialChecks({ token: 'token', openaiApiKey: 'sk-key' });
256
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
257
+ });
258
+ it('passes openaiBaseUrl through to checkOpenAiKey', async () => {
259
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
260
+ vi.stubGlobal('fetch', fetchSpy);
261
+ await runCredentialChecks({
262
+ token: 'token',
263
+ openaiApiKey: 'sk-key',
264
+ openaiBaseUrl: 'https://custom.example.com/v1',
265
+ });
266
+ const openAiCall = fetchSpy.mock.calls.find((args) => typeof args[0] === 'string' && args[0].includes('custom.example.com'));
267
+ expect(openAiCall).toBeDefined();
268
+ });
269
+ it('omits openai-key result when openai is not in activeProviders (key present)', async () => {
270
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
271
+ vi.stubGlobal('fetch', fetchSpy);
272
+ const report = await runCredentialChecks({
273
+ token: 'valid-token',
274
+ openaiApiKey: 'sk-stale',
275
+ activeProviders: new Set(['claude']),
276
+ });
277
+ expect(report.results.find((r) => r.name === 'openai-key')).toBeUndefined();
278
+ // fetch should only be called once (for discord)
279
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
280
+ });
281
+ it('runs the openai-key check when openai is in activeProviders', async () => {
282
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
283
+ vi.stubGlobal('fetch', fetchSpy);
284
+ const report = await runCredentialChecks({
285
+ token: 'valid-token',
286
+ openaiApiKey: 'sk-valid',
287
+ activeProviders: new Set(['openai']),
288
+ });
289
+ const openaiResult = report.results.find((r) => r.name === 'openai-key');
290
+ expect(openaiResult).toBeDefined();
291
+ expect(openaiResult?.status).toBe('ok');
292
+ });
293
+ it('preserves current behavior (runs openai check) when activeProviders is omitted', async () => {
294
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
295
+ vi.stubGlobal('fetch', fetchSpy);
296
+ const report = await runCredentialChecks({
297
+ token: 'valid-token',
298
+ openaiApiKey: 'sk-valid',
299
+ });
300
+ const openaiResult = report.results.find((r) => r.name === 'openai-key');
301
+ expect(openaiResult).toBeDefined();
302
+ expect(openaiResult?.status).toBe('ok');
303
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
304
+ });
305
+ it('omits openrouter-key result when openrouter is not in activeProviders (key present)', async () => {
306
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
307
+ vi.stubGlobal('fetch', fetchSpy);
308
+ const report = await runCredentialChecks({
309
+ token: 'valid-token',
310
+ openrouterApiKey: 'sk-or-stale',
311
+ activeProviders: new Set(['claude']),
312
+ });
313
+ expect(report.results.find((r) => r.name === 'openrouter-key')).toBeUndefined();
314
+ // fetch should only be called once (for discord)
315
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
316
+ });
317
+ it('runs the openrouter-key check when openrouter is in activeProviders', async () => {
318
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
319
+ vi.stubGlobal('fetch', fetchSpy);
320
+ const report = await runCredentialChecks({
321
+ token: 'valid-token',
322
+ openrouterApiKey: 'sk-or-valid',
323
+ activeProviders: new Set(['openrouter']),
324
+ });
325
+ const openrouterResult = report.results.find((r) => r.name === 'openrouter-key');
326
+ expect(openrouterResult).toBeDefined();
327
+ expect(openrouterResult?.status).toBe('ok');
328
+ });
329
+ it('runs both openai and openrouter checks concurrently when both are in activeProviders', async () => {
330
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
331
+ vi.stubGlobal('fetch', fetchSpy);
332
+ const report = await runCredentialChecks({
333
+ token: 'valid-token',
334
+ openaiApiKey: 'sk-valid',
335
+ openrouterApiKey: 'sk-or-valid',
336
+ activeProviders: new Set(['openai', 'openrouter']),
337
+ });
338
+ expect(report.results.find((r) => r.name === 'openai-key')).toBeDefined();
339
+ expect(report.results.find((r) => r.name === 'openrouter-key')).toBeDefined();
340
+ // discord + openai + openrouter = 3 fetch calls
341
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
342
+ });
343
+ it('passes openrouterBaseUrl through to checkOpenRouterKey', async () => {
344
+ const fetchSpy = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
345
+ vi.stubGlobal('fetch', fetchSpy);
346
+ await runCredentialChecks({
347
+ token: 'token',
348
+ openrouterApiKey: 'sk-or-key',
349
+ openrouterBaseUrl: 'https://custom-or.example.com/v1',
350
+ activeProviders: new Set(['openrouter']),
351
+ });
352
+ const openRouterCall = fetchSpy.mock.calls.find((args) => typeof args[0] === 'string' && args[0].includes('custom-or.example.com'));
353
+ expect(openRouterCall).toBeDefined();
354
+ });
355
+ });
356
+ // formatCredentialReport ---------------------------------------------------
357
+ describe('formatCredentialReport', () => {
358
+ it('formats a single ok result', () => {
359
+ const report = makeReport([{ name: 'discord-token', status: 'ok' }]);
360
+ expect(formatCredentialReport(report)).toBe('discord-token: ok');
361
+ });
362
+ it('formats a single skip result', () => {
363
+ const report = makeReport([{ name: 'openai-key', status: 'skip' }]);
364
+ expect(formatCredentialReport(report)).toBe('openai-key: skip');
365
+ });
366
+ it('formats a fail result with uppercase FAIL tag', () => {
367
+ const report = makeReport([
368
+ { name: 'discord-token', status: 'fail', message: 'invalid or revoked token (401)' },
369
+ ]);
370
+ const out = formatCredentialReport(report);
371
+ expect(out).toContain('FAIL');
372
+ expect(out).toContain('discord-token');
373
+ });
374
+ it('includes the message in parentheses for a fail result', () => {
375
+ const report = makeReport([
376
+ { name: 'discord-token', status: 'fail', message: 'network error: ECONNREFUSED' },
377
+ ]);
378
+ expect(formatCredentialReport(report)).toContain('(network error: ECONNREFUSED)');
379
+ });
380
+ it('omits parentheses when there is no message', () => {
381
+ const report = makeReport([{ name: 'discord-token', status: 'ok' }]);
382
+ expect(formatCredentialReport(report)).toBe('discord-token: ok');
383
+ });
384
+ it('joins multiple results with ", "', () => {
385
+ const report = makeReport([
386
+ { name: 'discord-token', status: 'ok' },
387
+ { name: 'openai-key', status: 'skip' },
388
+ ]);
389
+ const out = formatCredentialReport(report);
390
+ expect(out).toBe('discord-token: ok, openai-key: skip');
391
+ });
392
+ it('formats a mixed ok/fail report correctly', () => {
393
+ const report = makeReport([
394
+ { name: 'discord-token', status: 'ok' },
395
+ { name: 'openai-key', status: 'fail', message: 'invalid or expired key (401)' },
396
+ ]);
397
+ const out = formatCredentialReport(report);
398
+ expect(out).toContain('discord-token: ok');
399
+ expect(out).toContain('openai-key: FAIL (invalid or expired key (401))');
400
+ });
401
+ });
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs/promises';
2
+ /**
3
+ * Scenario 2: Remove stale cron run-stats records for threads that no longer exist.
4
+ *
5
+ * Iterates all records in the persistent stats store. For each record, attempts
6
+ * to fetch the Discord thread. If the thread is gone (null return or Discord
7
+ * error code 10003 / HTTP 404), removes the record via `statsStore.removeByThreadId()`
8
+ * and logs a structured warning.
9
+ *
10
+ * Non-404 errors (network failures, rate-limits, etc.) are treated as transient:
11
+ * the record is preserved and a fetch-error warning is logged instead.
12
+ *
13
+ * All remove-path errors are caught and logged (fail-open) to prevent healing
14
+ * from becoming a startup crash path.
15
+ */
16
+ export async function healStaleCronRecords(statsStore, client, log) {
17
+ const jobs = Object.values(statsStore.getStore().jobs);
18
+ for (const record of jobs) {
19
+ const { cronId, threadId } = record;
20
+ let threadGone = false;
21
+ try {
22
+ const channel = await client.channels.fetch(threadId);
23
+ if (channel === null || channel === undefined) {
24
+ threadGone = true;
25
+ }
26
+ }
27
+ catch (err) {
28
+ // Treat Discord "Unknown Channel" (code 10003) or HTTP 404 as definitely gone.
29
+ // Any other error is treated as transient — skip to avoid purging live records.
30
+ const code = err?.code;
31
+ const status = err?.status ?? err?.httpStatus;
32
+ if (code === 10003 || status === 404) {
33
+ threadGone = true;
34
+ }
35
+ else {
36
+ log?.warn({ cronId, threadId, err: err instanceof Error ? err.message : String(err) }, 'startup:heal:cron fetch error — skipping record');
37
+ continue;
38
+ }
39
+ }
40
+ if (!threadGone)
41
+ continue;
42
+ try {
43
+ await statsStore.removeByThreadId(threadId);
44
+ log?.warn({ cronId, threadId }, 'startup:heal:cron removed stale stats record for deleted thread');
45
+ }
46
+ catch (err) {
47
+ log?.warn({ cronId, threadId, err: err instanceof Error ? err.message : String(err) }, 'startup:heal:cron failed to remove stale stats record — continuing');
48
+ }
49
+ }
50
+ }
51
+ /**
52
+ * Scenario 3: Surface stale task thread references for deleted Discord threads.
53
+ *
54
+ * Iterates non-closed tasks with an `external_ref` of the form `discord:<threadId>`.
55
+ * If the thread no longer exists (null return or Discord 10003/404), logs a structured
56
+ * warning. Does NOT modify `external_ref` — the next task sync run may recreate the
57
+ * thread and re-link it.
58
+ *
59
+ * Non-404 errors are logged as a separate fetch-error warning and skipped.
60
+ * Never throws; all errors are caught and logged.
61
+ */
62
+ export async function healStaleTaskThreadRefs(store, client, log) {
63
+ const tasks = store.list(); // excludes closed tasks by default
64
+ for (const task of tasks) {
65
+ const ref = task.external_ref;
66
+ if (!ref?.startsWith('discord:'))
67
+ continue;
68
+ const threadId = ref.slice('discord:'.length);
69
+ if (!threadId)
70
+ continue;
71
+ try {
72
+ const channel = await client.channels.fetch(threadId);
73
+ if (channel === null || channel === undefined) {
74
+ log?.warn({ taskId: task.id, threadId }, 'startup:heal:task thread no longer exists (external_ref retained for next sync)');
75
+ }
76
+ }
77
+ catch (err) {
78
+ const code = err?.code;
79
+ const status = err?.status ?? err?.httpStatus;
80
+ if (code === 10003 || status === 404) {
81
+ log?.warn({ taskId: task.id, threadId }, 'startup:heal:task thread no longer exists (external_ref retained for next sync)');
82
+ }
83
+ else {
84
+ log?.warn({ taskId: task.id, threadId, err: err instanceof Error ? err.message : String(err) }, 'startup:heal:task thread fetch error — skipping');
85
+ }
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Scenario 4: Back up and remove corrupted JSON store files before they are loaded.
91
+ *
92
+ * For each `{ path, label }` entry: reads the file, attempts `JSON.parse`, and
93
+ * on failure copies the corrupted file to `<path>.corrupt.<ISO-timestamp>` then
94
+ * removes the original. Downstream loaders all handle missing files gracefully,
95
+ * so the net effect is a safe reset.
96
+ *
97
+ * - ENOENT (file not found) is not corruption — those entries are silently skipped.
98
+ * - Unreadable files (non-ENOENT) are warned and skipped.
99
+ * - Backup/remove failures are logged and skipped (fail-open).
100
+ */
101
+ export async function healCorruptedJsonStores(paths, log) {
102
+ for (const { path: filePath, label } of paths) {
103
+ let raw;
104
+ try {
105
+ raw = await fs.readFile(filePath, 'utf-8');
106
+ }
107
+ catch (err) {
108
+ if (err.code === 'ENOENT')
109
+ continue; // Not corruption.
110
+ log?.warn({ label, path: filePath, err: err instanceof Error ? err.message : String(err) }, 'startup:heal:json unreadable — skipping');
111
+ continue;
112
+ }
113
+ try {
114
+ JSON.parse(raw);
115
+ // Valid JSON — nothing to do.
116
+ }
117
+ catch (parseErr) {
118
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
119
+ const backupPath = `${filePath}.corrupt.${timestamp}`;
120
+ try {
121
+ await fs.copyFile(filePath, backupPath);
122
+ await fs.unlink(filePath);
123
+ log?.warn({
124
+ label,
125
+ path: filePath,
126
+ backupPath,
127
+ parseError: parseErr instanceof Error ? parseErr.message : String(parseErr),
128
+ }, 'startup:heal:json corrupted — backed up and removed');
129
+ }
130
+ catch (fsErr) {
131
+ log?.warn({
132
+ label,
133
+ path: filePath,
134
+ err: fsErr instanceof Error ? fsErr.message : String(fsErr),
135
+ }, 'startup:heal:json backup/remove failed — leaving file in place');
136
+ }
137
+ }
138
+ }
139
+ }