@wardby/cli 0.5.3 → 0.6.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 (370) hide show
  1. package/.env.example +33 -0
  2. package/README.md +25 -14
  3. package/dist/claude-coding-worker/context.d.ts +22 -0
  4. package/dist/claude-coding-worker/context.js +92 -0
  5. package/dist/claude-coding-worker/driver.d.ts +7 -1
  6. package/dist/claude-coding-worker/driver.js +38 -5
  7. package/dist/claude-coding-worker/sdk.js +13 -4
  8. package/dist/cli-help.d.ts +1 -1
  9. package/dist/cli-help.js +5 -2
  10. package/dist/cli-native-mode.d.ts +9 -0
  11. package/dist/cli-native-mode.js +20 -0
  12. package/dist/cli.d.ts +3 -1
  13. package/dist/cli.js +141 -5
  14. package/dist/coding/claude-context.d.ts +45 -0
  15. package/dist/coding/claude-context.js +434 -0
  16. package/dist/coding/profile.d.ts +12 -0
  17. package/dist/coding/profile.js +6 -0
  18. package/dist/coding/protocol.d.ts +104 -0
  19. package/dist/coding/protocol.js +87 -3
  20. package/dist/coding-worker/artifact.d.ts +8 -0
  21. package/dist/coding-worker/artifact.js +2 -2
  22. package/dist/coding-worker/driver.js +2 -0
  23. package/dist/coding-worker/sdk.d.ts +6 -0
  24. package/dist/coding-worker/sdk.js +1 -0
  25. package/dist/coding-worker/skills.d.ts +23 -0
  26. package/dist/coding-worker/skills.js +117 -0
  27. package/dist/coding-worker/types.d.ts +2 -0
  28. package/dist/config/github-allowed-accounts.d.ts +15 -0
  29. package/dist/config/github-allowed-accounts.js +36 -0
  30. package/dist/config/low-power.d.ts +12 -0
  31. package/dist/config/low-power.js +18 -0
  32. package/dist/config/mobile.d.ts +12 -0
  33. package/dist/config/mobile.js +18 -0
  34. package/dist/config/providers.d.ts +53 -0
  35. package/dist/config/providers.js +92 -6
  36. package/dist/core/db.d.ts +16 -6
  37. package/dist/core/db.js +12 -10
  38. package/dist/core/dispatch.d.ts +11 -0
  39. package/dist/core/dispatch.js +36 -1
  40. package/dist/core/engine-native.js +9 -5
  41. package/dist/core/host-events.d.ts +49 -3
  42. package/dist/core/host-events.js +289 -15
  43. package/dist/core/issue-bridge.js +42 -2
  44. package/dist/core/issue-events.js +12 -1
  45. package/dist/core/issue-status.js +14 -3
  46. package/dist/core/merge-order-check.d.ts +129 -0
  47. package/dist/core/merge-order-check.js +336 -0
  48. package/dist/core/notification-dispatcher.d.ts +70 -0
  49. package/dist/core/notification-dispatcher.js +368 -0
  50. package/dist/core/notification-templates.d.ts +25 -0
  51. package/dist/core/notification-templates.js +105 -0
  52. package/dist/core/notifications.d.ts +10 -0
  53. package/dist/core/notifications.js +38 -0
  54. package/dist/core/pg-auth.d.ts +22 -0
  55. package/dist/core/pg-auth.js +152 -0
  56. package/dist/core/power-composition.d.ts +16 -0
  57. package/dist/core/power-composition.js +37 -0
  58. package/dist/core/power-idle.d.ts +14 -0
  59. package/dist/core/power-idle.js +30 -0
  60. package/dist/core/power.d.ts +71 -0
  61. package/dist/core/power.js +202 -0
  62. package/dist/core/reconciler.d.ts +4 -2
  63. package/dist/core/reconciler.js +29 -5
  64. package/dist/core/related-pull-requests.d.ts +48 -4
  65. package/dist/core/related-pull-requests.js +113 -11
  66. package/dist/core/repo-access.d.ts +15 -0
  67. package/dist/core/repo-access.js +66 -0
  68. package/dist/core/review-fix.d.ts +37 -0
  69. package/dist/core/review-fix.js +207 -6
  70. package/dist/core/review-host-tools.d.ts +20 -0
  71. package/dist/core/review-host-tools.js +58 -2
  72. package/dist/core/runner.d.ts +336 -3
  73. package/dist/core/runner.js +894 -317
  74. package/dist/core/secret-broker-config.d.ts +103 -0
  75. package/dist/core/secret-broker-config.js +112 -0
  76. package/dist/core/secrets.d.ts +39 -4
  77. package/dist/core/secrets.js +148 -40
  78. package/dist/core/workflow-event-recorder.d.ts +14 -0
  79. package/dist/core/workflow-event-recorder.js +120 -0
  80. package/dist/core/workflow-events.d.ts +82 -0
  81. package/dist/core/workflow-events.js +44 -0
  82. package/dist/core/workflow-run-events.d.ts +16 -0
  83. package/dist/core/workflow-run-events.js +40 -0
  84. package/dist/generated/prisma/browser.d.ts +58 -0
  85. package/dist/generated/prisma/client.d.ts +58 -0
  86. package/dist/generated/prisma/commonInputTypes.d.ts +150 -0
  87. package/dist/generated/prisma/enums.d.ts +27 -0
  88. package/dist/generated/prisma/enums.js +23 -0
  89. package/dist/generated/prisma/internal/class.d.ts +88 -0
  90. package/dist/generated/prisma/internal/class.js +4 -4
  91. package/dist/generated/prisma/internal/prismaNamespace.d.ts +772 -1
  92. package/dist/generated/prisma/internal/prismaNamespace.js +135 -4
  93. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +139 -0
  94. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -4
  95. package/dist/generated/prisma/models/Agent.d.ts +360 -1
  96. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +73 -1
  97. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  98. package/dist/generated/prisma/models/DeferredReview.d.ts +78 -1
  99. package/dist/generated/prisma/models/NativeGatewayCall.d.ts +1675 -0
  100. package/dist/generated/prisma/models/NativeGatewayCall.js +1 -0
  101. package/dist/generated/prisma/models/NativeGatewaySession.d.ts +1517 -0
  102. package/dist/generated/prisma/models/NativeGatewaySession.js +1 -0
  103. package/dist/generated/prisma/models/NativeWarmWorker.d.ts +1296 -0
  104. package/dist/generated/prisma/models/NativeWarmWorker.js +1 -0
  105. package/dist/generated/prisma/models/NotificationChannel.d.ts +1543 -0
  106. package/dist/generated/prisma/models/NotificationChannel.js +1 -0
  107. package/dist/generated/prisma/models/NotificationDelivery.d.ts +1502 -0
  108. package/dist/generated/prisma/models/NotificationDelivery.js +1 -0
  109. package/dist/generated/prisma/models/NotificationThread.d.ts +1090 -0
  110. package/dist/generated/prisma/models/NotificationThread.js +1 -0
  111. package/dist/generated/prisma/models/Run.d.ts +826 -1
  112. package/dist/generated/prisma/models/RunHostCheck.d.ts +36 -1
  113. package/dist/generated/prisma/models/Secret.d.ts +37 -1
  114. package/dist/generated/prisma/models/SecretBrokerChange.d.ts +1101 -0
  115. package/dist/generated/prisma/models/SecretBrokerChange.js +1 -0
  116. package/dist/generated/prisma/models/WorkflowEvent.d.ts +1422 -0
  117. package/dist/generated/prisma/models/WorkflowEvent.js +1 -0
  118. package/dist/generated/prisma/models.d.ts +8 -0
  119. package/dist/help/build.js +4 -2
  120. package/dist/help/catalog.d.ts +1 -1
  121. package/dist/help/catalog.js +31 -6
  122. package/dist/help-index.json +1363 -176
  123. package/dist/import/create.js +17 -0
  124. package/dist/import/neutral-schema.d.ts +6 -2
  125. package/dist/import/neutral-schema.js +2 -0
  126. package/dist/mcp/auth/access.d.ts +1 -0
  127. package/dist/mcp/auth/repo-authorization.js +14 -0
  128. package/dist/mcp/auth/resource-server.d.ts +2 -1
  129. package/dist/mcp/auth/resource-server.js +7 -2
  130. package/dist/mcp/auth/self-hosted/browser.d.ts +1 -1
  131. package/dist/mcp/auth/self-hosted/browser.js +19 -6
  132. package/dist/mcp/context.d.ts +6 -0
  133. package/dist/mcp/host-events/github-ingress.d.ts +23 -1
  134. package/dist/mcp/host-events/github-ingress.js +68 -0
  135. package/dist/mcp/index.d.ts +13 -0
  136. package/dist/mcp/index.js +59 -10
  137. package/dist/mcp/tools/agents.js +82 -11
  138. package/dist/mcp/tools/notification-channels.d.ts +2 -0
  139. package/dist/mcp/tools/notification-channels.js +293 -0
  140. package/dist/mcp/tools/power.d.ts +2 -0
  141. package/dist/mcp/tools/power.js +77 -0
  142. package/dist/mcp/tools/runs.d.ts +0 -6
  143. package/dist/mcp/tools/runs.js +7 -2
  144. package/dist/mcp/tools/secret-broker-compat.d.ts +12 -0
  145. package/dist/mcp/tools/secret-broker-compat.js +23 -0
  146. package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -2
  147. package/dist/mcp/tools/secret-elicitation-form.js +200 -10
  148. package/dist/mcp/tools/secret-elicitation-server.js +8 -1
  149. package/dist/mcp/tools/secret-elicitation.d.ts +38 -3
  150. package/dist/mcp/tools/secret-elicitation.js +110 -12
  151. package/dist/mcp/tools/secrets.js +184 -7
  152. package/dist/mcp/tools/tools.d.ts +1 -1
  153. package/dist/mcp/tools/tools.js +22 -2
  154. package/dist/native-worker/composition.d.ts +23 -0
  155. package/dist/native-worker/composition.js +77 -0
  156. package/dist/native-worker/docker-isolation.d.ts +65 -0
  157. package/dist/native-worker/docker-isolation.js +183 -0
  158. package/dist/native-worker/docker-launcher.d.ts +74 -0
  159. package/dist/native-worker/docker-launcher.js +237 -0
  160. package/dist/native-worker/gateway-server.d.ts +39 -0
  161. package/dist/native-worker/gateway-server.js +315 -0
  162. package/dist/native-worker/gateway.d.ts +44 -0
  163. package/dist/native-worker/gateway.js +197 -0
  164. package/dist/native-worker/http-server.d.ts +22 -0
  165. package/dist/native-worker/http-server.js +109 -0
  166. package/dist/native-worker/http-transport.d.ts +17 -0
  167. package/dist/native-worker/http-transport.js +88 -0
  168. package/dist/native-worker/kubernetes-isolation.d.ts +90 -0
  169. package/dist/native-worker/kubernetes-isolation.js +218 -0
  170. package/dist/native-worker/kubernetes-launcher.d.ts +102 -0
  171. package/dist/native-worker/kubernetes-launcher.js +335 -0
  172. package/dist/native-worker/launch.d.ts +45 -0
  173. package/dist/native-worker/launch.js +44 -0
  174. package/dist/native-worker/ledger.d.ts +109 -0
  175. package/dist/native-worker/ledger.js +192 -0
  176. package/dist/native-worker/loopback.d.ts +12 -0
  177. package/dist/native-worker/loopback.js +49 -0
  178. package/dist/native-worker/main.d.ts +7 -0
  179. package/dist/native-worker/main.js +74 -0
  180. package/dist/native-worker/protocol.d.ts +744 -0
  181. package/dist/native-worker/protocol.js +200 -0
  182. package/dist/native-worker/sandbox-executor.d.ts +68 -0
  183. package/dist/native-worker/sandbox-executor.js +210 -0
  184. package/dist/native-worker/stdio.d.ts +29 -0
  185. package/dist/native-worker/stdio.js +188 -0
  186. package/dist/native-worker/warm-delivery.d.ts +16 -0
  187. package/dist/native-worker/warm-delivery.js +33 -0
  188. package/dist/native-worker/warm-pool-ledger.d.ts +39 -0
  189. package/dist/native-worker/warm-pool-ledger.js +73 -0
  190. package/dist/native-worker/warm-pool.d.ts +107 -0
  191. package/dist/native-worker/warm-pool.js +283 -0
  192. package/dist/native-worker/worker.d.ts +17 -0
  193. package/dist/native-worker/worker.js +115 -0
  194. package/dist/providers/auth/authorization-server.d.ts +3 -0
  195. package/dist/providers/auth/index.js +2 -0
  196. package/dist/providers/auth/self-hosted.d.ts +13 -1
  197. package/dist/providers/auth/self-hosted.js +54 -4
  198. package/dist/providers/chat/fake.d.ts +46 -0
  199. package/dist/providers/chat/fake.js +39 -0
  200. package/dist/providers/chat/index.d.ts +4 -0
  201. package/dist/providers/chat/index.js +8 -0
  202. package/dist/providers/chat/slack.d.ts +37 -0
  203. package/dist/providers/chat/slack.js +105 -0
  204. package/dist/providers/chat/types.d.ts +42 -0
  205. package/dist/providers/chat/types.js +19 -0
  206. package/dist/providers/engine/types.d.ts +19 -0
  207. package/dist/providers/engine/types.js +22 -0
  208. package/dist/providers/executor/composition.d.ts +24 -1
  209. package/dist/providers/executor/composition.js +39 -6
  210. package/dist/providers/executor/container.d.ts +24 -1
  211. package/dist/providers/executor/container.js +101 -11
  212. package/dist/providers/executor/dbos.d.ts +42 -0
  213. package/dist/providers/executor/dbos.js +118 -12
  214. package/dist/providers/executor/deferred.d.ts +56 -0
  215. package/dist/providers/executor/deferred.js +119 -0
  216. package/dist/providers/executor/routing.d.ts +37 -10
  217. package/dist/providers/executor/routing.js +73 -32
  218. package/dist/providers/executor/types.d.ts +12 -0
  219. package/dist/providers/jobs/docker.js +3 -3
  220. package/dist/providers/jobs/fake-kubernetes-api.d.ts +21 -2
  221. package/dist/providers/jobs/fake-kubernetes-api.js +42 -1
  222. package/dist/providers/jobs/kubernetes-api.d.ts +17 -1
  223. package/dist/providers/jobs/kubernetes-api.js +7 -0
  224. package/dist/providers/jobs/kubernetes-client.d.ts +10 -1
  225. package/dist/providers/jobs/kubernetes-client.js +52 -3
  226. package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +6 -1
  227. package/dist/providers/jobs/kubernetes-dry-run-fixture.js +6 -3
  228. package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
  229. package/dist/providers/jobs/kubernetes-isolation.js +6 -0
  230. package/dist/providers/jobs/kubernetes-platform.d.ts +4 -1
  231. package/dist/providers/jobs/kubernetes-platform.js +38 -1
  232. package/dist/providers/jobs/kubernetes-preflight.d.ts +2 -1
  233. package/dist/providers/jobs/kubernetes-preflight.js +4 -2
  234. package/dist/providers/jobs/kubernetes.d.ts +36 -1
  235. package/dist/providers/jobs/kubernetes.js +74 -4
  236. package/dist/providers/jobs/metadata-probes.d.ts +11 -0
  237. package/dist/providers/jobs/metadata-probes.js +13 -0
  238. package/dist/providers/jobs/types.d.ts +9 -0
  239. package/dist/providers/review-host/github.d.ts +11 -1
  240. package/dist/providers/review-host/github.js +50 -0
  241. package/dist/providers/review-host/types.d.ts +23 -0
  242. package/dist/providers/vcs/github.d.ts +32 -6
  243. package/dist/providers/vcs/github.js +74 -16
  244. package/dist/quickstart/coding-seed.d.ts +10 -3
  245. package/dist/quickstart/coding-seed.js +74 -3
  246. package/dist/quickstart/images.d.ts +2 -0
  247. package/dist/quickstart/images.js +7 -1
  248. package/dist/quickstart/reviewer-prompt.d.ts +1 -1
  249. package/dist/quickstart/reviewer-prompt.js +1 -1
  250. package/dist/quickstart-images.json +1 -1
  251. package/dist/sandbox/brokered-fetch.d.ts +23 -0
  252. package/dist/sandbox/brokered-fetch.js +37 -0
  253. package/dist/sandbox/host-functions.d.ts +34 -0
  254. package/dist/sandbox/host-functions.js +144 -80
  255. package/dist/sandbox/prelude.js +7 -1
  256. package/dist/sandbox/run-in-sandbox.d.ts +19 -0
  257. package/dist/sandbox/run-in-sandbox.js +28 -10
  258. package/dist/sandbox/safe-fetch.d.ts +2 -0
  259. package/dist/sandbox/safe-fetch.js +3 -1
  260. package/dist/sandbox/secret-broker-sigv4.d.ts +2 -0
  261. package/dist/sandbox/secret-broker-sigv4.js +49 -0
  262. package/dist/sandbox/secret-broker.d.ts +34 -0
  263. package/dist/sandbox/secret-broker.js +162 -0
  264. package/dist/sandbox/user-tool.d.ts +14 -0
  265. package/dist/sandbox/user-tool.js +32 -0
  266. package/dist/serve.js +18 -0
  267. package/dist/viewer/api-schema.d.ts +210 -14
  268. package/dist/viewer/api-schema.js +22 -0
  269. package/dist/viewer/build-schemas.js +4 -1
  270. package/dist/viewer/event-bus.d.ts +6 -0
  271. package/dist/viewer/event-bus.js +16 -7
  272. package/dist/viewer/graph.d.ts +5 -2
  273. package/dist/viewer/graph.js +7 -2
  274. package/dist/viewer/http.d.ts +0 -1
  275. package/dist/viewer/http.js +2 -2
  276. package/dist/viewer/infra.js +25 -2
  277. package/dist/viewer/limits.d.ts +4 -0
  278. package/dist/viewer/limits.js +4 -0
  279. package/dist/viewer/openapi.d.ts +15 -0
  280. package/dist/viewer/openapi.js +172 -0
  281. package/docs/README.md +7 -0
  282. package/docs/agent-recipes.md +57 -8
  283. package/docs/architecture-runtime.md +16 -1
  284. package/docs/code-review-agents.md +139 -5
  285. package/docs/coding-agent-setup.md +113 -5
  286. package/docs/coding-packages.md +4 -2
  287. package/docs/coding-services.md +3 -1
  288. package/docs/coding-worker-byo-images.md +198 -6
  289. package/docs/coding-worker-isolation.md +68 -14
  290. package/docs/getting-started-gke.md +225 -9
  291. package/docs/getting-started-identity-provider.md +21 -10
  292. package/docs/getting-started.md +5 -4
  293. package/docs/jira-agents.md +5 -1
  294. package/docs/native-sandbox.md +635 -0
  295. package/docs/release-verification.md +9 -0
  296. package/docs/security-deployment.md +113 -12
  297. package/docs/slack-notifications.md +252 -0
  298. package/docs/tool-secrets.md +252 -0
  299. package/docs/viewer-api.md +46 -5
  300. package/docs/viewer-api.openapi.yml +1404 -0
  301. package/help/admin-viewer.md +6 -2
  302. package/help/agent-recipes.md +27 -0
  303. package/help/architecture-agent.md +1 -1
  304. package/help/brokered-secrets.md +79 -0
  305. package/help/build-claude-code-image.md +131 -0
  306. package/help/build-worker-image.md +3 -6
  307. package/help/code-review-agents.md +68 -6
  308. package/help/deploy-gke.md +8 -1
  309. package/help/deployment-targets.md +25 -14
  310. package/help/errors/coding-provider-not-configured.md +2 -2
  311. package/help/errors/database-auth-invalid.md +28 -0
  312. package/help/errors/github-account-not-allowed.md +25 -0
  313. package/help/errors/low-power-disabled.md +29 -0
  314. package/help/errors/low-power-scale-forbidden.md +35 -0
  315. package/help/errors/low-power-wake-timeout.md +30 -0
  316. package/help/errors/mobile-disabled.md +31 -0
  317. package/help/errors/native-sandbox-capacity.md +22 -0
  318. package/help/errors/native-sandbox-deadline-exceeded.md +19 -0
  319. package/help/errors/native-sandbox-docker-failed.md +20 -0
  320. package/help/errors/native-sandbox-gateway-unavailable.md +21 -0
  321. package/help/errors/native-sandbox-image-not-pinned.md +19 -0
  322. package/help/errors/native-sandbox-isolation-mismatch.md +20 -0
  323. package/help/errors/native-sandbox-network-unenforced.md +22 -0
  324. package/help/errors/native-sandbox-requires-catalog.md +31 -0
  325. package/help/errors/native-sandbox-unavailable.md +19 -0
  326. package/help/errors/native-sandbox-warm-delivery-failed.md +21 -0
  327. package/help/errors/native-sandbox-worker-exited.md +20 -0
  328. package/help/errors/native-sandbox-worker-lost.md +20 -0
  329. package/help/errors/native-sandbox-worker-unready.md +22 -0
  330. package/help/errors/secret-broker-body-invalid.md +26 -0
  331. package/help/errors/secret-broker-changed.md +20 -0
  332. package/help/errors/secret-broker-config-invalid.md +30 -0
  333. package/help/errors/secret-broker-conflict.md +27 -0
  334. package/help/errors/secret-broker-destination-denied.md +28 -0
  335. package/help/errors/secret-broker-value-invalid.md +28 -0
  336. package/help/errors/secret-brokered.md +24 -0
  337. package/help/errors/secret-not-brokered.md +25 -0
  338. package/help/errors/slack-auth-failed.md +48 -0
  339. package/help/errors/slack-channel-unreachable.md +39 -0
  340. package/help/errors/slack-not-configured.md +33 -0
  341. package/help/getting-started.md +4 -5
  342. package/help/github.md +18 -1
  343. package/help/identity-and-access.md +5 -1
  344. package/help/local-repositories.md +2 -3
  345. package/help/low-power-mode.md +125 -0
  346. package/help/merge-order-check.md +117 -0
  347. package/help/mobile-app.md +54 -0
  348. package/help/native-sandbox.md +136 -0
  349. package/help/observability.md +8 -0
  350. package/help/related-pull-requests.md +70 -9
  351. package/help/repo-instructions-and-skills.md +91 -0
  352. package/help/review-fix-rounds.md +10 -0
  353. package/help/slack-notifications.md +62 -0
  354. package/help/troubleshooting/coding-workers.md +21 -0
  355. package/package.json +11 -4
  356. package/prisma/migrations/20261009000003_native_execution_mode/migration.sql +12 -0
  357. package/prisma/migrations/20261009000004_native_gateway_ledger/migration.sql +65 -0
  358. package/prisma/migrations/20261009000005_native_gateway_call_child_agent/migration.sql +5 -0
  359. package/prisma/migrations/20261009000006_run_deferred_start_stop/migration.sql +14 -0
  360. package/prisma/migrations/20261009000007_native_gateway_network_ready/migration.sql +5 -0
  361. package/prisma/migrations/20261010000001_workflow_notifications/migration.sql +99 -0
  362. package/prisma/migrations/20261011000001_native_gateway_session_status_index/migration.sql +2 -0
  363. package/prisma/migrations/20261012000001_native_warm_worker/migration.sql +32 -0
  364. package/prisma/migrations/20261013000001_coding_run_merge_order/migration.sql +2 -0
  365. package/prisma/migrations/20261013000001_secret_broker/migration.sql +17 -0
  366. package/prisma/migrations/20261013000002_deferred_review_request/migration.sql +4 -0
  367. package/prisma/migrations/20261014000000_repo_skills/migration.sql +15 -0
  368. package/prisma/migrations/20261015000001_coding_run_merge_order_index/migration.sql +2 -0
  369. package/prisma/migrations/20261015000002_no_change_rereview/migration.sql +2 -0
  370. package/prisma/schema.prisma +362 -91
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Delivers pending NotificationDelivery rows to chat (Slack), one thread per
3
+ * work item (or PR, or run) per channel.
4
+ *
5
+ * Rules:
6
+ * - Lease-gated: only the holder of the "notification-dispatch" SchedulerLease
7
+ * delivers. The lease is renewed before every delivery and the pass ends
8
+ * when renewal fails; a pass also stops after PASS_BUDGET_MS so a delivery
9
+ * always starts well inside the lease TTL. Replicas therefore don't
10
+ * double-post while the lease is held. Delivery is still at-least-once: a
11
+ * failed "delivered" write after a successful post retries (and reposts).
12
+ * - Threads: the first delivery for a (provider, channel, threadKey) posts a
13
+ * parent message and stores its ts in NotificationThread; every event is a
14
+ * reply under it. The thread status is derived from its delivered events;
15
+ * NotificationThread.status is the status last rendered on the parent, which
16
+ * is re-rendered with chat.update whenever the two differ. A deleted parent
17
+ * (message_not_found on update) drops the thread row; the next event posts
18
+ * a fresh parent.
19
+ * - Ordering: within a thread, deliveries go strictly oldest-first. A thread is
20
+ * only worked when its oldest pending delivery is due, and it stops at its
21
+ * first failure in a pass, so a reply never overtakes an older one.
22
+ * - Pacing: at most one Slack call per second per channel (posts and updates).
23
+ * - Errors: rate_limited defers by Retry-After without counting an attempt and
24
+ * holds the whole channel (every thread on it) until Retry-After passes;
25
+ * transient (and any non-ChatError) backs
26
+ * off exponentially and fails at MAX_ATTEMPTS; channel_unreachable fails
27
+ * every pending delivery for the channel and stamps lastError on its links;
28
+ * auth_failed pauses the whole dispatcher for 5 minutes (no attempt counted),
29
+ * then re-checks each provider with auth.test.
30
+ * - Sender: with WARDBY_SLACK_CUSTOMIZE on, a message's display name is the
31
+ * agent the event names (picked up, review, failure), else "wardby".
32
+ * - The pause and channel holds live on `deps.state` (created on first use), not in module
33
+ * state: one dispatcher (or one test) per deps object.
34
+ */
35
+ import { Prisma } from "#prisma";
36
+ import { ChatError, } from "../providers/chat/types.js";
37
+ import { tryAcquireLease } from "./lease.js";
38
+ import { logger } from "./logger.js";
39
+ import { broadcasts, nextStatus, renderEvent, renderParent, senderName, } from "./notification-templates.js";
40
+ import { spendLine } from "./issue-status.js";
41
+ import { shortReason } from "./workflow-events.js";
42
+ const log = logger.child({ module: "notification-dispatcher" });
43
+ export const DISPATCH_LEASE_SCOPE = "notification-dispatch";
44
+ export const MAX_ATTEMPTS = 10;
45
+ const LEASE_TTL_MS = 30_000;
46
+ /** A pass stops starting deliveries after this long; the rest wait for the next tick. */
47
+ const PASS_BUDGET_MS = 20_000;
48
+ const BATCH = 100;
49
+ const AUTH_PAUSE_MS = 5 * 60_000;
50
+ const AUTH_RECHECK_RETRY_MS = 30_000;
51
+ const CHANNEL_GAP_MS = 1000;
52
+ const PRUNE_EVERY_MS = 3_600_000;
53
+ /** Exponential back-off: 5s · 2^(attempts-1), capped at 1h. */
54
+ export function backoffMs(attempts) {
55
+ return Math.min(5000 * 2 ** (Math.max(1, attempts) - 1), 3_600_000);
56
+ }
57
+ const channelKey = (d) => `${d.provider}\u0000${d.channelId}`;
58
+ /** Holds every thread on the channel until `retryAfterMs` from now (across passes). */
59
+ function holdChannel(pass, d, retryAfterMs) {
60
+ const key = channelKey(d);
61
+ pass.blocked.add(key);
62
+ const holds = (pass.state.channelBlockedUntil ??= new Map());
63
+ holds.set(key, new Date(pass.now.getTime() + (retryAfterMs ?? CHANNEL_GAP_MS)));
64
+ }
65
+ async function pace(pass, key) {
66
+ const last = pass.lastCall.get(key);
67
+ if (last !== undefined) {
68
+ const wait = CHANNEL_GAP_MS - (Date.now() - last);
69
+ if (wait > 0)
70
+ await (pass.deps.sleep ?? defaultSleep)(wait);
71
+ }
72
+ pass.lastCall.set(key, Date.now());
73
+ }
74
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
75
+ async function subjectOf(db, d) {
76
+ const ev = d.event;
77
+ if (ev.workItemProvider && ev.workItemKey) {
78
+ const item = await db.workItem.findUnique({
79
+ where: { provider_key: { provider: ev.workItemProvider, key: ev.workItemKey } },
80
+ select: { title: true, url: true },
81
+ });
82
+ return { label: ev.workItemKey, title: item?.title ?? null, url: item?.url ?? null };
83
+ }
84
+ if (ev.repository && ev.prNumber != null) {
85
+ const url = ev.codeProvider === "github" ? `https://github.com/${ev.repository}/pull/${ev.prNumber}` : null;
86
+ return { label: `${ev.repository}#${ev.prNumber}`, title: null, url };
87
+ }
88
+ if (ev.runId)
89
+ return { label: `run ${ev.runId.slice(0, 8)}`, title: null, url: null };
90
+ return { label: d.threadKey, title: null, url: null };
91
+ }
92
+ /** Posts the thread's parent and records it; a lost create race adopts the winner's parent. */
93
+ async function openThread(pass, provider, d, payload) {
94
+ const { db } = pass.deps;
95
+ const status = nextStatus(null, payload);
96
+ const subject = await subjectOf(db, d);
97
+ await pace(pass, channelKey(d));
98
+ const { ts } = await provider.postMessage(d.channelId, {
99
+ ...renderParent(subject, status),
100
+ username: senderName(payload),
101
+ });
102
+ const where = { provider: d.provider, channelId: d.channelId, threadKey: d.threadKey };
103
+ try {
104
+ return await db.notificationThread.create({ data: { ...where, parentTs: ts, status } });
105
+ }
106
+ catch (err) {
107
+ if (!(err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"))
108
+ throw err;
109
+ log.warn({ channelId: d.channelId, threadKey: d.threadKey, ts }, "lost a thread-parent race; extra parent posted");
110
+ return db.notificationThread.findUniqueOrThrow({ where: { provider_channelId_threadKey: where } });
111
+ }
112
+ }
113
+ /** The thread's status as implied by its delivered events, oldest first (terminal statuses stick). */
114
+ async function derivedStatus(db, d) {
115
+ const rows = await db.notificationDelivery.findMany({
116
+ where: { provider: d.provider, channelId: d.channelId, threadKey: d.threadKey, state: "delivered" },
117
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
118
+ select: { event: { select: { payload: true } } },
119
+ });
120
+ return rows.reduce((s, r) => nextStatus(s, r.event.payload), null);
121
+ }
122
+ /**
123
+ * Re-renders the parent when the derived status differs from the one last
124
+ * rendered (NotificationThread.status). Re-derives once after an edit so a
125
+ * status another replica delivered meanwhile still reaches the parent. A
126
+ * failed edit leaves the status alone: the next event re-derives and retries.
127
+ * Never throws: the reply is already delivered.
128
+ */
129
+ async function refreshParent(pass, provider, d) {
130
+ const { db } = pass.deps;
131
+ const key = { provider: d.provider, channelId: d.channelId, threadKey: d.threadKey };
132
+ let threadId = null;
133
+ try {
134
+ let subject = null;
135
+ for (let round = 0; round < 2; round++) {
136
+ const thread = await db.notificationThread.findUnique({ where: { provider_channelId_threadKey: key } });
137
+ const derived = await derivedStatus(db, d);
138
+ if (!thread || !derived || derived === thread.status)
139
+ return;
140
+ threadId = thread.id;
141
+ subject ??= await subjectOf(db, d);
142
+ await pace(pass, channelKey(d));
143
+ await provider.updateMessage(d.channelId, thread.parentTs, renderParent(subject, derived));
144
+ await db.notificationThread.updateMany({ where: { id: thread.id }, data: { status: derived } });
145
+ }
146
+ }
147
+ catch (err) {
148
+ if (err instanceof ChatError && err.code === "message_not_found" && threadId) {
149
+ log.info({ ...key }, "thread parent is gone; next event starts a new one");
150
+ await db.notificationThread.deleteMany({ where: { id: threadId } }).catch((e) => {
151
+ log.warn({ err: e, threadId }, "could not drop the stale thread row");
152
+ });
153
+ return;
154
+ }
155
+ if (err instanceof ChatError && err.code === "rate_limited")
156
+ holdChannel(pass, d, err.retryAfterMs);
157
+ log.warn({ err, ...key }, "could not update the thread parent status");
158
+ }
159
+ }
160
+ async function deliver(pass, provider, d) {
161
+ const { db } = pass.deps;
162
+ const payload = d.event.payload;
163
+ try {
164
+ const where = { provider: d.provider, channelId: d.channelId, threadKey: d.threadKey };
165
+ const thread = (await db.notificationThread.findUnique({ where: { provider_channelId_threadKey: where } })) ??
166
+ (await openThread(pass, provider, d, payload));
167
+ const spend = d.includeCost && d.event.runId ? (await spendLine(db, d.event.runId)) || null : null;
168
+ await pace(pass, channelKey(d));
169
+ const msg = { ...renderEvent(payload, spend), username: senderName(payload) };
170
+ const { ts } = await provider.postMessage(d.channelId, msg, {
171
+ threadTs: thread.parentTs,
172
+ broadcast: broadcasts(payload),
173
+ });
174
+ await db.notificationDelivery.update({
175
+ where: { id: d.id },
176
+ data: { state: "delivered", deliveredAt: pass.now, messageTs: ts, lastError: null, attempts: { increment: 1 } },
177
+ });
178
+ await refreshParent(pass, provider, d);
179
+ return "ok";
180
+ }
181
+ catch (err) {
182
+ return handleFailure(pass, d, err);
183
+ }
184
+ }
185
+ async function handleFailure(pass, d, err) {
186
+ const { db } = pass.deps;
187
+ const { now } = pass;
188
+ const chat = err instanceof ChatError ? err : null;
189
+ const message = chat?.slackError ?? shortReason(err instanceof Error ? err.message : String(err)) ?? "unknown error";
190
+ switch (chat?.code) {
191
+ case "rate_limited":
192
+ holdChannel(pass, d, chat.retryAfterMs);
193
+ await db.notificationDelivery.update({
194
+ where: { id: d.id },
195
+ data: { nextAttemptAt: new Date(now.getTime() + (chat.retryAfterMs ?? CHANNEL_GAP_MS)) },
196
+ });
197
+ return "stop_thread";
198
+ case "channel_unreachable":
199
+ pass.blocked.add(channelKey(d));
200
+ log.warn({ provider: d.provider, channelId: d.channelId, error: message }, "channel unreachable; failing its deliveries");
201
+ await db.notificationDelivery.updateMany({
202
+ where: { provider: d.provider, channelId: d.channelId, state: "pending" },
203
+ data: { state: "failed", lastError: message },
204
+ });
205
+ await db.notificationChannel.updateMany({
206
+ where: { provider: d.provider, channelId: d.channelId },
207
+ data: { lastError: message, lastErrorAt: now },
208
+ });
209
+ return "stop_thread";
210
+ case "auth_failed":
211
+ pass.state.pausedUntil = new Date(now.getTime() + AUTH_PAUSE_MS);
212
+ log.error({ provider: d.provider, error: message }, "chat auth failed; pausing notification delivery for 5 minutes");
213
+ return "stop_pass";
214
+ default: {
215
+ if (chat?.code === "message_not_found") {
216
+ // The thread's parent is gone (reply to a deleted parent): start over on the retry.
217
+ await db.notificationThread.deleteMany({
218
+ where: { provider: d.provider, channelId: d.channelId, threadKey: d.threadKey },
219
+ });
220
+ }
221
+ const attempts = d.attempts + 1;
222
+ const failed = attempts >= MAX_ATTEMPTS;
223
+ if (!chat)
224
+ log.warn({ err, deliveryId: d.id }, "notification delivery failed");
225
+ await db.notificationDelivery.update({
226
+ where: { id: d.id },
227
+ data: {
228
+ attempts,
229
+ lastError: message,
230
+ ...(failed ? { state: "failed" } : { nextAttemptAt: new Date(now.getTime() + backoffMs(attempts)) }),
231
+ },
232
+ });
233
+ return "stop_thread";
234
+ }
235
+ }
236
+ }
237
+ /** Expired pause: auth.test every provider; true when delivery may resume. */
238
+ async function recheckAuth(deps, state, now) {
239
+ for (const provider of Object.values(deps.chat)) {
240
+ if (!provider)
241
+ continue;
242
+ try {
243
+ await provider.authTest();
244
+ }
245
+ catch (err) {
246
+ const stillRevoked = err instanceof ChatError && err.code === "auth_failed";
247
+ state.pausedUntil = new Date(now.getTime() + (stillRevoked ? AUTH_PAUSE_MS : AUTH_RECHECK_RETRY_MS));
248
+ if (!stillRevoked)
249
+ log.warn({ err, provider: provider.name }, "auth re-check failed; retrying shortly");
250
+ return false;
251
+ }
252
+ }
253
+ state.pausedUntil = undefined;
254
+ log.info("chat auth restored; resuming notification delivery");
255
+ return true;
256
+ }
257
+ /** One pass: deliver what is due. Returns the number of deliveries attempted. */
258
+ export async function dispatchOnce(deps) {
259
+ const state = (deps.state ??= {});
260
+ const clock = deps.now ?? (() => new Date());
261
+ const now = clock();
262
+ const { db } = deps;
263
+ if (!(await tryAcquireLease(db, DISPATCH_LEASE_SCOPE, deps.holder, LEASE_TTL_MS)))
264
+ return 0;
265
+ if (state.pausedUntil) {
266
+ if (state.pausedUntil > now)
267
+ return 0;
268
+ if (!(await recheckAuth(deps, state, now)))
269
+ return 0;
270
+ }
271
+ const holds = state.channelBlockedUntil;
272
+ for (const [key, until] of holds ?? [])
273
+ if (until <= now)
274
+ holds?.delete(key);
275
+ const scope = deps.channelIdPrefix ? { channelId: { startsWith: deps.channelIdPrefix } } : {};
276
+ const due = await db.notificationDelivery.findMany({
277
+ where: { state: "pending", nextAttemptAt: { lte: now }, ...scope },
278
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
279
+ take: BATCH,
280
+ include: { event: true },
281
+ });
282
+ const threads = new Map();
283
+ for (const d of due) {
284
+ const key = `${channelKey(d)}\u0000${d.threadKey}`;
285
+ const group = threads.get(key);
286
+ if (group)
287
+ group.push(d);
288
+ else
289
+ threads.set(key, [d]);
290
+ }
291
+ const pass = { deps, state, now, blocked: new Set(), lastCall: new Map() };
292
+ let attempted = 0;
293
+ for (const group of threads.values()) {
294
+ const head = group[0];
295
+ const provider = deps.chat[head.provider];
296
+ if (!provider || pass.blocked.has(channelKey(head)) || holds?.has(channelKey(head)))
297
+ continue;
298
+ // An older pending delivery that is not yet due holds the whole thread.
299
+ const older = await db.notificationDelivery.findFirst({
300
+ where: {
301
+ provider: head.provider,
302
+ channelId: head.channelId,
303
+ threadKey: head.threadKey,
304
+ state: "pending",
305
+ createdAt: { lt: head.createdAt },
306
+ },
307
+ select: { id: true },
308
+ });
309
+ if (older)
310
+ continue;
311
+ for (const d of group) {
312
+ if (pass.blocked.has(channelKey(d)))
313
+ break;
314
+ if (attempted > 0 && clock().getTime() - now.getTime() >= PASS_BUDGET_MS)
315
+ return attempted;
316
+ if (!(await tryAcquireLease(db, DISPATCH_LEASE_SCOPE, deps.holder, LEASE_TTL_MS))) {
317
+ log.warn({ holder: deps.holder }, "lost the dispatch lease mid-pass; stopping");
318
+ return attempted;
319
+ }
320
+ attempted++;
321
+ const outcome = await deliver(pass, provider, d);
322
+ if (outcome === "stop_pass")
323
+ return attempted;
324
+ if (outcome === "stop_thread")
325
+ break;
326
+ }
327
+ }
328
+ return attempted;
329
+ }
330
+ /**
331
+ * Deletes events (and their deliveries) older than `days` with no pending
332
+ * delivery. `only` narrows the candidates further (tests scope it to their own rows).
333
+ */
334
+ export async function pruneWorkflowEvents(db, now, days = 30, only = {}) {
335
+ const cutoff = new Date(now.getTime() - days * 86_400_000);
336
+ const { count } = await db.workflowEvent.deleteMany({
337
+ where: { ...only, createdAt: { lt: cutoff }, deliveries: { none: { state: "pending" } } },
338
+ });
339
+ return count;
340
+ }
341
+ export function startNotificationDispatcher(deps) {
342
+ deps.state ??= {};
343
+ let running = false;
344
+ let lastPrune = 0;
345
+ const tick = async () => {
346
+ if (running)
347
+ return;
348
+ running = true;
349
+ try {
350
+ await dispatchOnce(deps);
351
+ const nowMs = Date.now();
352
+ if (nowMs - lastPrune >= PRUNE_EVERY_MS) {
353
+ lastPrune = nowMs;
354
+ const pruned = await pruneWorkflowEvents(deps.db, new Date(nowMs));
355
+ if (pruned > 0)
356
+ log.info({ pruned }, "pruned old workflow events");
357
+ }
358
+ }
359
+ finally {
360
+ running = false;
361
+ }
362
+ };
363
+ const timer = setInterval(() => {
364
+ tick().catch((err) => log.error({ err }, "notification dispatch tick failed"));
365
+ }, deps.intervalMs ?? 2000);
366
+ timer.unref?.();
367
+ return { stop: () => clearInterval(timer) };
368
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Deterministic Slack text for workflow notifications. Pure functions: no
3
+ * I/O, no model output. Every string that came from a person or a model
4
+ * (titles, agent names, reasons) is escaped so it cannot become a link or a
5
+ * mention; only canonical tracker/host URLs are formatted as links.
6
+ */
7
+ import type { ThreadSubject, WorkflowPayload } from "./workflow-events.js";
8
+ export type ThreadStatus = "picked up" | "PR open" | "changes requested" | "fixing" | "fix rounds exhausted" | "approved" | "merged ✅" | "closed" | "failed ❌";
9
+ export declare function escapeMrkdwn(s: string): string;
10
+ export declare function nextStatus(prev: ThreadStatus | null, p: WorkflowPayload): ThreadStatus;
11
+ export declare function renderParent(subject: ThreadSubject, status: ThreadStatus): {
12
+ text: string;
13
+ blocks: unknown[];
14
+ };
15
+ export declare function renderEvent(p: WorkflowPayload, spend: string | null): {
16
+ text: string;
17
+ blocks: unknown[];
18
+ };
19
+ /**
20
+ * The Slack display name for a message about this event: the agent's name
21
+ * when the event names one, otherwise "wardby". Only takes effect when
22
+ * WARDBY_SLACK_CUSTOMIZE is on (chat:write.customize); no icon is set.
23
+ */
24
+ export declare function senderName(p: WorkflowPayload): string;
25
+ export declare function broadcasts(p: WorkflowPayload): boolean;
@@ -0,0 +1,105 @@
1
+ const TERMINAL = new Set(["merged ✅", "closed"]);
2
+ /** Events that start a new attempt (a new pickup, a new PR) and so reopen a merged/closed thread. */
3
+ const REOPENS = new Set(["issue_picked_up", "pr_opened"]);
4
+ export function escapeMrkdwn(s) {
5
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6
+ }
7
+ /** A URL is only linked when it is https; the label is escaped. */
8
+ function link(url, label) {
9
+ const safe = escapeMrkdwn(label);
10
+ return url && url.startsWith("https://") && !/[<>|\s]/.test(url) ? `<${url}|${safe}>` : safe;
11
+ }
12
+ export function nextStatus(prev, p) {
13
+ const terminal = prev !== null && TERMINAL.has(prev);
14
+ if (terminal && !REOPENS.has(p.kind))
15
+ return prev;
16
+ switch (p.kind) {
17
+ case "issue_picked_up":
18
+ return terminal ? "picked up" : (prev ?? "picked up");
19
+ case "pr_opened":
20
+ return "PR open";
21
+ case "review_posted":
22
+ if (p.verdict === "APPROVE")
23
+ return "approved";
24
+ if (p.verdict === "CHANGES_REQUESTED")
25
+ return "changes requested";
26
+ return prev ?? "PR open";
27
+ case "review_fix":
28
+ return p.state === "started" ? "fixing" : "fix rounds exhausted";
29
+ case "pr_closed":
30
+ return p.merged ? "merged ✅" : "closed";
31
+ case "run_failed":
32
+ return "failed ❌";
33
+ }
34
+ }
35
+ const section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
36
+ export function renderParent(subject, status) {
37
+ const head = link(subject.url, subject.label) + (subject.title ? ` · ${escapeMrkdwn(subject.title)}` : "");
38
+ const text = `${head} — *${status}*`;
39
+ return { text, blocks: [section(text)] };
40
+ }
41
+ export function renderEvent(p, spend) {
42
+ let line;
43
+ switch (p.kind) {
44
+ case "issue_picked_up":
45
+ line = `🟢 Picked up by *${escapeMrkdwn(p.agentName)}* (${escapeMrkdwn(p.trigger)})`;
46
+ break;
47
+ case "pr_opened":
48
+ line = `🔀 PR ${link(p.prUrl, p.prLabel)} is ready${p.movedTo ? ` — moved to ${escapeMrkdwn(p.movedTo)}` : ""}`;
49
+ break;
50
+ case "review_posted": {
51
+ const who = `*${escapeMrkdwn(p.agentName)}*`;
52
+ const target = link(p.prUrl, p.prLabel);
53
+ line =
54
+ p.verdict === "APPROVE"
55
+ ? `✅ ${who} approved ${target}`
56
+ : p.verdict === "CHANGES_REQUESTED"
57
+ ? `📝 ${who} requested changes on ${target}`
58
+ : `💬 ${who} commented on ${target}${p.ciPending ? " (CI still running)" : ""}`;
59
+ break;
60
+ }
61
+ case "review_fix":
62
+ line =
63
+ p.state === "started"
64
+ ? `🔁 Fix round ${p.round} of ${p.maxRounds} started on ${escapeMrkdwn(p.prLabel)}`
65
+ : `🛑 Still changes requested after ${p.maxRounds} fix rounds on ${escapeMrkdwn(p.prLabel)} — needs a person`;
66
+ break;
67
+ case "pr_closed":
68
+ line = p.merged
69
+ ? `🎉 ${link(p.prUrl, p.prLabel)} merged${p.movedTo ? ` — moved to ${escapeMrkdwn(p.movedTo)}` : ""}`
70
+ : `⚪ ${link(p.prUrl, p.prLabel)} closed without merging`;
71
+ break;
72
+ case "run_failed": {
73
+ const who = `*${escapeMrkdwn(p.agentName)}*`;
74
+ line =
75
+ p.status === "budget_exhausted"
76
+ ? `❌ ${who} stopped: budget exhausted`
77
+ : p.status === "lost"
78
+ ? `❌ ${who} was lost (no heartbeat)`
79
+ : p.status === "cancelled"
80
+ ? `❌ ${who} was cancelled`
81
+ : `❌ ${who} ${p.status === "refused" ? "was refused" : "failed"}${p.reason ? `: ${escapeMrkdwn(p.reason)}` : ""}`;
82
+ break;
83
+ }
84
+ }
85
+ const text = spend ? `${line}\n_${escapeMrkdwn(spend)}_` : line;
86
+ return { text, blocks: [section(text)] };
87
+ }
88
+ /**
89
+ * The Slack display name for a message about this event: the agent's name
90
+ * when the event names one, otherwise "wardby". Only takes effect when
91
+ * WARDBY_SLACK_CUSTOMIZE is on (chat:write.customize); no icon is set.
92
+ */
93
+ export function senderName(p) {
94
+ switch (p.kind) {
95
+ case "issue_picked_up":
96
+ case "run_failed":
97
+ case "review_posted":
98
+ return p.agentName;
99
+ default:
100
+ return "wardby";
101
+ }
102
+ }
103
+ export function broadcasts(p) {
104
+ return (p.kind === "pr_closed" && p.merged) || p.kind === "run_failed";
105
+ }
@@ -0,0 +1,10 @@
1
+ import type { PrismaClient } from "#prisma";
2
+ import type { ChatProviderRegistry } from "../providers/chat/types.js";
3
+ export declare function installWorkflowEventRecorder(db: PrismaClient, chat: ChatProviderRegistry): void;
4
+ export declare function startNotifications(opts: {
5
+ db: PrismaClient;
6
+ chat: ChatProviderRegistry;
7
+ holder?: string;
8
+ }): Promise<{
9
+ stop(): void;
10
+ }>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Process wiring for workflow notifications: installs the outbox recorder
3
+ * as the workflow-event sink and starts the lease-gated dispatcher. Safe in
4
+ * every replica (the dispatcher elects one sender via SchedulerLease).
5
+ * Without a configured chat provider this does nothing at all.
6
+ */
7
+ import { randomUUID } from "node:crypto";
8
+ import { logger } from "./logger.js";
9
+ import { startNotificationDispatcher } from "./notification-dispatcher.js";
10
+ import { createWorkflowEventRecorder } from "./workflow-event-recorder.js";
11
+ import { setWorkflowEventSink } from "./workflow-events.js";
12
+ const log = logger.child({ module: "notifications" });
13
+ export function installWorkflowEventRecorder(db, chat) {
14
+ const enabled = Object.keys(chat);
15
+ if (enabled.length === 0)
16
+ return;
17
+ setWorkflowEventSink(createWorkflowEventRecorder(db, enabled));
18
+ }
19
+ export async function startNotifications(opts) {
20
+ if (Object.keys(opts.chat).length === 0)
21
+ return { stop() { } };
22
+ installWorkflowEventRecorder(opts.db, opts.chat);
23
+ // Identity checks run in the background: Slack being slow or unreachable
24
+ // must never delay process startup. The dispatcher starts immediately
25
+ // regardless; a failed check just logs (dispatch itself re-checks auth
26
+ // on an auth_failed delivery error).
27
+ for (const provider of Object.values(opts.chat)) {
28
+ void provider
29
+ .authTest()
30
+ .then((who) => {
31
+ log.info({ provider: provider.name, team: who.team, botUserId: who.botUserId }, "chat notifications acting as");
32
+ })
33
+ .catch((err) => {
34
+ log.error({ err, provider: provider.name }, "chat provider auth check failed; deliveries will pause until it succeeds");
35
+ });
36
+ }
37
+ return startNotificationDispatcher({ db: opts.db, chat: opts.chat, holder: opts.holder ?? `notify-${randomUUID()}` });
38
+ }
@@ -0,0 +1,22 @@
1
+ import type { ClientConfig } from "pg";
2
+ export type DatabaseAuth = "password" | "rds-iam";
3
+ export declare const DATABASE_AUTH_ERROR = "database_auth_invalid";
4
+ export declare const RDS_IAM_TOKEN_REUSE_MS = 600000;
5
+ export interface TokenSigner {
6
+ getAuthToken(): Promise<string>;
7
+ }
8
+ export interface PgAuthOptions {
9
+ env?: NodeJS.ProcessEnv;
10
+ makeSigner?: (p: {
11
+ hostname: string;
12
+ port: number;
13
+ username: string;
14
+ region: string;
15
+ }) => TokenSigner;
16
+ now?: () => number;
17
+ readFile?: (path: string) => string;
18
+ }
19
+ export declare function databaseAuthFromEnv(env?: NodeJS.ProcessEnv): DatabaseAuth;
20
+ /** One token at a time: concurrent connects share a signing call; a failure is never cached. */
21
+ export declare function cachedToken(signer: TokenSigner, now?: () => number, reuseMs?: number): () => Promise<string>;
22
+ export declare function pgConnectionConfig(url: string, auth: DatabaseAuth, options?: PgAuthOptions): ClientConfig;