@k2b/cloud 0.6.0-rc.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 (540) hide show
  1. package/README.md +98 -0
  2. package/package.json +128 -0
  3. package/public/logo.svg +1 -0
  4. package/scripts/app-favicon.test.ts +49 -0
  5. package/scripts/app-favicon.ts +51 -0
  6. package/scripts/build.ts +205 -0
  7. package/scripts/preload.ts +164 -0
  8. package/scripts/sync-recovery-smoke.ts +200 -0
  9. package/src/_internal/app-favicon.ts +2 -0
  10. package/src/_internal/app-presentation.ts +61 -0
  11. package/src/_internal/app-workspace-state.ts +24 -0
  12. package/src/_internal/bounded-json.ts +47 -0
  13. package/src/_internal/build-metadata.ts +9 -0
  14. package/src/_internal/capabilities.ts +1118 -0
  15. package/src/_internal/define-app.ts +801 -0
  16. package/src/_internal/heartbeat.ts +167 -0
  17. package/src/_internal/help-catalog.ts +241 -0
  18. package/src/_internal/help.ts +95 -0
  19. package/src/_internal/nats-connection.ts +34 -0
  20. package/src/_internal/page-responses.ts +40 -0
  21. package/src/_internal/process-sync.ts +117 -0
  22. package/src/_internal/readiness.ts +3 -0
  23. package/src/_internal/registry-validation.ts +136 -0
  24. package/src/_internal/registry-watch.ts +52 -0
  25. package/src/_internal/registry.ts +338 -0
  26. package/src/_internal/runtime-compatibility.ts +64 -0
  27. package/src/_internal/runtime-context.ts +84 -0
  28. package/src/_internal/runtime-watcher.ts +78 -0
  29. package/src/_internal/static-assets.ts +86 -0
  30. package/src/_internal/status-preserving-ssr.ts +49 -0
  31. package/src/access/PermissionEditor.tsx +486 -0
  32. package/src/access/ResourceApiKeys.tsx +271 -0
  33. package/src/access/messages.ts +121 -0
  34. package/src/access/ui.ts +16 -0
  35. package/src/account/Avatar.tsx +28 -0
  36. package/src/account/EntitySearch.tsx +363 -0
  37. package/src/account/avatar-upload-dialog.tsx +197 -0
  38. package/src/account/avatar-upload.ts +149 -0
  39. package/src/account/ui.ts +3 -0
  40. package/src/ai/admin.ts +47 -0
  41. package/src/ai/approval-routes.ts +130 -0
  42. package/src/ai/approvals.ts +139 -0
  43. package/src/ai/assistant-models.ts +61 -0
  44. package/src/ai/attachments.ts +40 -0
  45. package/src/ai/browser.ts +92 -0
  46. package/src/ai/capabilities.ts +714 -0
  47. package/src/ai/capability-execution.ts +151 -0
  48. package/src/ai/chat/blocks.render.test.tsx +1108 -0
  49. package/src/ai/chat/blocks.tsx +730 -0
  50. package/src/ai/chat/builtin-tools.tsx +428 -0
  51. package/src/ai/chat/capability-table.tsx +87 -0
  52. package/src/ai/chat/composer-adapter.ts +169 -0
  53. package/src/ai/chat/file-tools.tsx +81 -0
  54. package/src/ai/chat/message-actions.tsx +370 -0
  55. package/src/ai/chat/message-utils.ts +479 -0
  56. package/src/ai/chat/messages.ts +43 -0
  57. package/src/ai/chat/presentation.tsx +395 -0
  58. package/src/ai/chat/primitives.tsx +3 -0
  59. package/src/ai/chat/tool-disclosure.tsx +41 -0
  60. package/src/ai/chat/turn-navigator-utils.ts +28 -0
  61. package/src/ai/chat/turn-navigator.tsx +164 -0
  62. package/src/ai/chat/user-message.tsx +149 -0
  63. package/src/ai/chat/visual-tools.tsx +435 -0
  64. package/src/ai/chat/web-tools.tsx +202 -0
  65. package/src/ai/chat-task-contracts.ts +113 -0
  66. package/src/ai/chat-tasks.ts +952 -0
  67. package/src/ai/client/controller.ts +1211 -0
  68. package/src/ai/client/file-source.ts +95 -0
  69. package/src/ai/client/live-connection.ts +140 -0
  70. package/src/ai/client/projection.ts +178 -0
  71. package/src/ai/client/transport.ts +111 -0
  72. package/src/ai/compaction.ts +182 -0
  73. package/src/ai/credentials.ts +124 -0
  74. package/src/ai/default-tool-contracts.ts +108 -0
  75. package/src/ai/default-tools.ts +127 -0
  76. package/src/ai/enrich.ts +280 -0
  77. package/src/ai/executor.ts +1624 -0
  78. package/src/ai/fetch-file-tool.ts +313 -0
  79. package/src/ai/file-context.ts +92 -0
  80. package/src/ai/file-media-type.ts +28 -0
  81. package/src/ai/file-mount.ts +16 -0
  82. package/src/ai/file-tools.ts +566 -0
  83. package/src/ai/files-store.ts +390 -0
  84. package/src/ai/firecrawl-tools.ts +305 -0
  85. package/src/ai/grids-skill.ts +126 -0
  86. package/src/ai/http.ts +265 -0
  87. package/src/ai/index.ts +337 -0
  88. package/src/ai/kit-skill.ts +22 -0
  89. package/src/ai/limits.ts +5 -0
  90. package/src/ai/live-events.ts +289 -0
  91. package/src/ai/live-messages.ts +45 -0
  92. package/src/ai/live-outbox.ts +102 -0
  93. package/src/ai/live-routes.ts +412 -0
  94. package/src/ai/live.ts +2 -0
  95. package/src/ai/maintenance.ts +302 -0
  96. package/src/ai/markdown-pdf-tool.ts +95 -0
  97. package/src/ai/memories.ts +605 -0
  98. package/src/ai/memory-learning-runs.ts +222 -0
  99. package/src/ai/memory-learning.ts +619 -0
  100. package/src/ai/memory-tool.ts +120 -0
  101. package/src/ai/memory-workflow-evidence.ts +158 -0
  102. package/src/ai/migrate.ts +2082 -0
  103. package/src/ai/model-access-migrate.ts +30 -0
  104. package/src/ai/model-access.ts +191 -0
  105. package/src/ai/personal-agent.ts +6 -0
  106. package/src/ai/prefs.ts +72 -0
  107. package/src/ai/project-tool.ts +94 -0
  108. package/src/ai/projects-routes.ts +277 -0
  109. package/src/ai/projects.ts +902 -0
  110. package/src/ai/protocol.ts +279 -0
  111. package/src/ai/provider.ts +73 -0
  112. package/src/ai/public-projection.ts +25 -0
  113. package/src/ai/resource-markers.ts +31 -0
  114. package/src/ai/resource-refs.ts +70 -0
  115. package/src/ai/routes.ts +1131 -0
  116. package/src/ai/runtime-tools.ts +39 -0
  117. package/src/ai/runtime.ts +544 -0
  118. package/src/ai/settings.ts +529 -0
  119. package/src/ai/short-id.ts +45 -0
  120. package/src/ai/skill-catalog.ts +73 -0
  121. package/src/ai/skill-format.ts +145 -0
  122. package/src/ai/skill-seeds.ts +430 -0
  123. package/src/ai/skill-tool.ts +81 -0
  124. package/src/ai/skills-routes.ts +143 -0
  125. package/src/ai/skills.ts +934 -0
  126. package/src/ai/solid.ts +27 -0
  127. package/src/ai/store.ts +3113 -0
  128. package/src/ai/stream.ts +308 -0
  129. package/src/ai/structured-runs.ts +66 -0
  130. package/src/ai/structured.ts +186 -0
  131. package/src/ai/system-prompt.ts +170 -0
  132. package/src/ai/task-prompt.ts +22 -0
  133. package/src/ai/timeline.ts +148 -0
  134. package/src/ai/tool-audit.ts +177 -0
  135. package/src/ai/tool-result-budget.ts +16 -0
  136. package/src/ai/tools.ts +155 -0
  137. package/src/ai/types.ts +967 -0
  138. package/src/ai/ui.tsx +39 -0
  139. package/src/ai/usage-migrate.ts +81 -0
  140. package/src/ai/usage.ts +315 -0
  141. package/src/ai/validate.ts +54 -0
  142. package/src/ai/vision-tool.ts +79 -0
  143. package/src/api/accounts-entities.ts +174 -0
  144. package/src/api/admin-ai-projects.ts +74 -0
  145. package/src/api/admin-ai-skills.ts +76 -0
  146. package/src/api/admin-ai-usage.ts +42 -0
  147. package/src/api/admin-core-settings.ts +508 -0
  148. package/src/api/admin-identity.ts +148 -0
  149. package/src/api/admin-lifecycle.ts +206 -0
  150. package/src/api/admin-linux-identities.ts +66 -0
  151. package/src/api/announcements.ts +131 -0
  152. package/src/api/app-approval.ts +129 -0
  153. package/src/api/apps.ts +51 -0
  154. package/src/api/auth/schemas.ts +54 -0
  155. package/src/api/auth.ts +370 -0
  156. package/src/api/capabilities.ts +658 -0
  157. package/src/api/help-messages.ts +19 -0
  158. package/src/api/help.ts +78 -0
  159. package/src/api/index.ts +108 -0
  160. package/src/api/mcp.ts +744 -0
  161. package/src/api/me-rail.ts +45 -0
  162. package/src/api/me.ts +753 -0
  163. package/src/api/search/schemas.ts +79 -0
  164. package/src/api/search.bench.ts +185 -0
  165. package/src/api/search.ts +407 -0
  166. package/src/api/sync-ops.ts +79 -0
  167. package/src/api/widgets.ts +143 -0
  168. package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
  169. package/src/browser/CloudResourceSearch.tsx +791 -0
  170. package/src/browser/app-approval-vault.ts +305 -0
  171. package/src/browser/app-approval.ts +234 -0
  172. package/src/browser/live-websocket.ts +268 -0
  173. package/src/browser/locale-preference.ts +25 -0
  174. package/src/browser/notification-target.ts +27 -0
  175. package/src/browser/notification-timeout.ts +14 -0
  176. package/src/browser/notifications.ts +181 -0
  177. package/src/browser/resource-clipboard.ts +70 -0
  178. package/src/browser/resource-picker.tsx +39 -0
  179. package/src/browser/resource-search-messages.ts +55 -0
  180. package/src/browser/resource-search.ts +27 -0
  181. package/src/capabilities/client.ts +149 -0
  182. package/src/capabilities/response.ts +39 -0
  183. package/src/capabilities/server.ts +215 -0
  184. package/src/capabilities/signals.ts +4 -0
  185. package/src/capabilities/testing.ts +12 -0
  186. package/src/capabilities/types.ts +32 -0
  187. package/src/cli/access.ts +499 -0
  188. package/src/cli/account.ts +382 -0
  189. package/src/cli/admin/account-administration.ts +45 -0
  190. package/src/cli/admin/account-categories.ts +49 -0
  191. package/src/cli/admin/ai-usage.ts +118 -0
  192. package/src/cli/admin/app-sign-in.ts +49 -0
  193. package/src/cli/admin/data.ts +343 -0
  194. package/src/cli/admin/documentation.ts +34 -0
  195. package/src/cli/admin/gateway.ts +155 -0
  196. package/src/cli/admin/index.ts +61 -0
  197. package/src/cli/admin/instance.ts +250 -0
  198. package/src/cli/admin/jobs.ts +223 -0
  199. package/src/cli/admin/legal.ts +122 -0
  200. package/src/cli/admin/linux.ts +53 -0
  201. package/src/cli/admin/logs.ts +324 -0
  202. package/src/cli/admin/metrics.ts +142 -0
  203. package/src/cli/admin/nats.ts +167 -0
  204. package/src/cli/admin/notifications.ts +516 -0
  205. package/src/cli/admin/shared.ts +113 -0
  206. package/src/cli/admin/sync.ts +188 -0
  207. package/src/cli/admin/telemetry.ts +314 -0
  208. package/src/cli/admin/webhooks.ts +222 -0
  209. package/src/cli/admin/workflows.ts +367 -0
  210. package/src/cli/apps.ts +40 -0
  211. package/src/cli/capabilities.ts +239 -0
  212. package/src/cli/commands.ts +604 -0
  213. package/src/cli/index.ts +61 -0
  214. package/src/cli/locale.ts +31 -0
  215. package/src/cli/output.ts +58 -0
  216. package/src/clients/core.ts +20 -0
  217. package/src/config/env.ts +54 -0
  218. package/src/config/index.ts +6 -0
  219. package/src/contracts/account-categories.ts +62 -0
  220. package/src/contracts/announcements.ts +121 -0
  221. package/src/contracts/app-approval.ts +197 -0
  222. package/src/contracts/app.ts +162 -0
  223. package/src/contracts/capabilities.ts +484 -0
  224. package/src/contracts/index.ts +14 -0
  225. package/src/contracts/notification-live.ts +66 -0
  226. package/src/contracts/notification-types.ts +235 -0
  227. package/src/contracts/posix.ts +69 -0
  228. package/src/contracts/profile.ts +85 -0
  229. package/src/contracts/rail-preferences.ts +53 -0
  230. package/src/contracts/registry.ts +110 -0
  231. package/src/contracts/resource-clipboard.ts +33 -0
  232. package/src/contracts/settings-types.ts +98 -0
  233. package/src/contracts/shared.ts +408 -0
  234. package/src/contracts/user-notifications.ts +101 -0
  235. package/src/contracts/widgets.ts +183 -0
  236. package/src/desktop/index.ts +704 -0
  237. package/src/desktop/solid.tsx +938 -0
  238. package/src/index.ts +58 -0
  239. package/src/server/actor.ts +41 -0
  240. package/src/server/api/index.ts +2 -0
  241. package/src/server/api/respond.ts +96 -0
  242. package/src/server/api-client.ts +54 -0
  243. package/src/server/app-context.ts +39 -0
  244. package/src/server/help.ts +187 -0
  245. package/src/server/index.ts +89 -0
  246. package/src/server/locale.ts +55 -0
  247. package/src/server/middleware/auth.ts +386 -0
  248. package/src/server/middleware/index.ts +28 -0
  249. package/src/server/middleware/invocation.ts +55 -0
  250. package/src/server/middleware/middleware.ts +65 -0
  251. package/src/server/middleware/openapi.ts +126 -0
  252. package/src/server/middleware/rate-limit.ts +125 -0
  253. package/src/server/middleware/request-logger.ts +41 -0
  254. package/src/server/middleware/route-template.ts +46 -0
  255. package/src/server/middleware/runtime.ts +22 -0
  256. package/src/server/middleware/settings.ts +55 -0
  257. package/src/server/middleware/validator.ts +49 -0
  258. package/src/server/middleware/workload.ts +22 -0
  259. package/src/server/ratelimit.ts +151 -0
  260. package/src/server/request-cookies.ts +18 -0
  261. package/src/server/services/access.ts +644 -0
  262. package/src/server/services/freeipa/client.ts +137 -0
  263. package/src/server/services/freeipa/index.ts +9 -0
  264. package/src/server/services/freeipa/session.ts +123 -0
  265. package/src/server/services/freeipa/test-certificates.ts +44 -0
  266. package/src/server/services/freeipa/tls.ts +44 -0
  267. package/src/server/services/freeipa/transport.ts +116 -0
  268. package/src/server/services/freeipa/util.ts +59 -0
  269. package/src/server/services/geo.ts +198 -0
  270. package/src/server/services/index.ts +48 -0
  271. package/src/server/services/pagination.ts +22 -0
  272. package/src/server/services/services.ts +13 -0
  273. package/src/server/time.ts +33 -0
  274. package/src/services/account-category-policy.ts +51 -0
  275. package/src/services/account-lifecycle/audit.ts +41 -0
  276. package/src/services/account-lifecycle/index.ts +940 -0
  277. package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
  278. package/src/services/account-lifecycle/notification-sender.ts +13 -0
  279. package/src/services/account-lifecycle/scheduler.ts +506 -0
  280. package/src/services/account-model.ts +22 -0
  281. package/src/services/accounts/app.ts +1671 -0
  282. package/src/services/accounts/authz.ts +44 -0
  283. package/src/services/accounts/avatar.ts +120 -0
  284. package/src/services/accounts/base-group.ts +11 -0
  285. package/src/services/accounts/base-user.ts +46 -0
  286. package/src/services/accounts/duplicate-emails.ts +82 -0
  287. package/src/services/accounts/entities.ts +622 -0
  288. package/src/services/accounts/group-sql.ts +106 -0
  289. package/src/services/accounts/groups.ts +272 -0
  290. package/src/services/accounts/index.ts +14 -0
  291. package/src/services/accounts/ipa-data.ts +66 -0
  292. package/src/services/accounts/lifecycle.ts +2 -0
  293. package/src/services/accounts/local-groups.ts +499 -0
  294. package/src/services/accounts/model.ts +129 -0
  295. package/src/services/accounts/notification-sender.ts +31 -0
  296. package/src/services/accounts/posix.ts +386 -0
  297. package/src/services/accounts/request-policy.ts +11 -0
  298. package/src/services/accounts/switching.ts +126 -0
  299. package/src/services/accounts/users.ts +745 -0
  300. package/src/services/announcements/index.ts +224 -0
  301. package/src/services/app-approval.ts +498 -0
  302. package/src/services/audit/index.ts +438 -0
  303. package/src/services/auth-flows/index.ts +15 -0
  304. package/src/services/auth-flows/ipa.ts +197 -0
  305. package/src/services/auth-flows/magic-link.ts +185 -0
  306. package/src/services/auth-flows/notification-sender.ts +10 -0
  307. package/src/services/auth-flows/password-reset.ts +268 -0
  308. package/src/services/auth-flows/proxy-return.ts +49 -0
  309. package/src/services/document-extraction.ts +148 -0
  310. package/src/services/freeipa-config.ts +179 -0
  311. package/src/services/gateway.ts +186 -0
  312. package/src/services/identity/constants.ts +18 -0
  313. package/src/services/identity/index.ts +52 -0
  314. package/src/services/identity/invocation-actor.ts +120 -0
  315. package/src/services/identity/invocation-authority.ts +53 -0
  316. package/src/services/identity/invocation-operations.ts +15 -0
  317. package/src/services/identity/invocation-token.ts +276 -0
  318. package/src/services/identity/key-config.ts +41 -0
  319. package/src/services/identity/key-ring.ts +530 -0
  320. package/src/services/identity/metrics.ts +29 -0
  321. package/src/services/identity/routes.ts +18 -0
  322. package/src/services/identity/runtime-config.ts +88 -0
  323. package/src/services/identity/session-token.ts +152 -0
  324. package/src/services/identity/signer-refresh.ts +44 -0
  325. package/src/services/identity/workload-auth.ts +64 -0
  326. package/src/services/index.ts +187 -0
  327. package/src/services/ipa/auth.ts +216 -0
  328. package/src/services/ipa/connection.ts +57 -0
  329. package/src/services/ipa/effective-groups.ts +70 -0
  330. package/src/services/ipa/groups.ts +722 -0
  331. package/src/services/ipa/guard.ts +17 -0
  332. package/src/services/ipa/index.ts +17 -0
  333. package/src/services/ipa/posix.ts +19 -0
  334. package/src/services/ipa/profile.ts +132 -0
  335. package/src/services/ipa/runtime.ts +28 -0
  336. package/src/services/ipa/search.ts +154 -0
  337. package/src/services/ipa/service-account.ts +15 -0
  338. package/src/services/ipa/sync-planning.ts +100 -0
  339. package/src/services/ipa/sync.ts +862 -0
  340. package/src/services/ipa/users.ts +806 -0
  341. package/src/services/legal-consent.ts +76 -0
  342. package/src/services/logging/index.ts +477 -0
  343. package/src/services/logging/redaction.ts +20 -0
  344. package/src/services/logging/trace.ts +1231 -0
  345. package/src/services/mandates/index.ts +855 -0
  346. package/src/services/mandates/policy.ts +85 -0
  347. package/src/services/network-security.ts +31 -0
  348. package/src/services/notifications/batches.ts +722 -0
  349. package/src/services/notifications/browser.ts +252 -0
  350. package/src/services/notifications/catalog.ts +113 -0
  351. package/src/services/notifications/channels.ts +104 -0
  352. package/src/services/notifications/dispatcher.ts +139 -0
  353. package/src/services/notifications/email.ts +118 -0
  354. package/src/services/notifications/index.ts +633 -0
  355. package/src/services/notifications/live.ts +50 -0
  356. package/src/services/notifications/observability.ts +407 -0
  357. package/src/services/notifications/platform.ts +421 -0
  358. package/src/services/notifications/runtime.ts +120 -0
  359. package/src/services/notifications/user.ts +248 -0
  360. package/src/services/notifications/web-push-transport.ts +135 -0
  361. package/src/services/oauth-tokens.ts +209 -0
  362. package/src/services/outbox.ts +187 -0
  363. package/src/services/pdf/gotenberg.ts +290 -0
  364. package/src/services/pdf/index.ts +43 -0
  365. package/src/services/pdf/markdown.ts +209 -0
  366. package/src/services/pdf/template-preview.ts +122 -0
  367. package/src/services/postgres.ts +76 -0
  368. package/src/services/providers/index.ts +27 -0
  369. package/src/services/providers/local/auth.ts +51 -0
  370. package/src/services/providers/local/index.ts +4 -0
  371. package/src/services/providers/local/users.ts +263 -0
  372. package/src/services/rail-preferences.ts +27 -0
  373. package/src/services/runtime-lifecycle.ts +191 -0
  374. package/src/services/secrets.ts +10 -0
  375. package/src/services/service-account-credentials.ts +891 -0
  376. package/src/services/service-accounts.ts +184 -0
  377. package/src/services/session/index.ts +236 -0
  378. package/src/services/session/test-fixture.ts +18 -0
  379. package/src/services/session/user.ts +144 -0
  380. package/src/services/settings/api.ts +61 -0
  381. package/src/services/settings/app.ts +86 -0
  382. package/src/services/settings/core-settings.ts +796 -0
  383. package/src/services/settings/crypto.ts +69 -0
  384. package/src/services/settings/defaults.ts +374 -0
  385. package/src/services/settings/index.ts +215 -0
  386. package/src/services/settings/namespace.ts +9 -0
  387. package/src/services/settings/snapshot.ts +49 -0
  388. package/src/services/settings/store.ts +241 -0
  389. package/src/services/settings/templates.ts +4 -0
  390. package/src/services/sync-ops.ts +403 -0
  391. package/src/services/topic-cursor.ts +22 -0
  392. package/src/services/weather/forecast.ts +323 -0
  393. package/src/services/weather/geo.ts +119 -0
  394. package/src/services/weather/index.ts +67 -0
  395. package/src/services/weather/location.ts +24 -0
  396. package/src/services/weather/locations.ts +133 -0
  397. package/src/services/weather/migrate.ts +45 -0
  398. package/src/services/weather/settings.ts +66 -0
  399. package/src/services/weather/short-id.ts +57 -0
  400. package/src/services/weather/types.ts +61 -0
  401. package/src/services/weather/ui.ts +50 -0
  402. package/src/services/webauthn.ts +385 -0
  403. package/src/shared/account-action-notice.ts +70 -0
  404. package/src/shared/account-display.ts +15 -0
  405. package/src/shared/account-session.ts +15 -0
  406. package/src/shared/ai-platform-prompt.ts +134 -0
  407. package/src/shared/ai-usage.ts +49 -0
  408. package/src/shared/app-presentation.ts +48 -0
  409. package/src/shared/app-url.ts +12 -0
  410. package/src/shared/branding.ts +14 -0
  411. package/src/shared/capability-messages.ts +70 -0
  412. package/src/shared/email-html.ts +54 -0
  413. package/src/shared/format.ts +91 -0
  414. package/src/shared/help.ts +68 -0
  415. package/src/shared/icons.ts +400 -0
  416. package/src/shared/index.ts +54 -0
  417. package/src/shared/locale.ts +48 -0
  418. package/src/shared/login-method.ts +44 -0
  419. package/src/shared/markdown/client.ts +130 -0
  420. package/src/shared/markdown/extensions/code.ts +105 -0
  421. package/src/shared/markdown/extensions/guided-help.ts +100 -0
  422. package/src/shared/markdown/extensions/images.ts +79 -0
  423. package/src/shared/markdown/extensions/info-blocks.ts +97 -0
  424. package/src/shared/markdown/extensions/katex.ts +120 -0
  425. package/src/shared/markdown/extensions/links.ts +43 -0
  426. package/src/shared/markdown/extensions/mark.ts +48 -0
  427. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  428. package/src/shared/markdown/extensions/tables.ts +109 -0
  429. package/src/shared/markdown/extensions/task-list.ts +53 -0
  430. package/src/shared/markdown/formula.ts +1187 -0
  431. package/src/shared/markdown/index.ts +193 -0
  432. package/src/shared/markdown/shared.ts +36 -0
  433. package/src/shared/mock-cover.ts +130 -0
  434. package/src/shared/network-address.ts +106 -0
  435. package/src/shared/redirect.ts +56 -0
  436. package/src/shared/template-rendering.ts +140 -0
  437. package/src/shared/theme.ts +68 -0
  438. package/src/shared/time.ts +13 -0
  439. package/src/ssr/AdminLayout.tsx +53 -0
  440. package/src/ssr/AdminSidebar.tsx +50 -0
  441. package/src/ssr/AppLaunchpad.island.tsx +236 -0
  442. package/src/ssr/AppWorkspaceController.island.tsx +21 -0
  443. package/src/ssr/BrowserNotifications.island.tsx +135 -0
  444. package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
  445. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
  446. package/src/ssr/GlobalSearchDialog.tsx +35 -0
  447. package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
  448. package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
  449. package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
  450. package/src/ssr/Layout.render.test.tsx +148 -0
  451. package/src/ssr/Layout.tsx +292 -0
  452. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  453. package/src/ssr/LayoutFooter.tsx +22 -0
  454. package/src/ssr/LayoutHeader.tsx +81 -0
  455. package/src/ssr/LayoutHelp.tsx +869 -0
  456. package/src/ssr/LayoutPreferences.island.tsx +36 -0
  457. package/src/ssr/LayoutRail.tsx +58 -0
  458. package/src/ssr/MinimalLayout.tsx +46 -0
  459. package/src/ssr/PageError.tsx +29 -0
  460. package/src/ssr/ProfilePreferences.island.tsx +92 -0
  461. package/src/ssr/RailApps.island.tsx +61 -0
  462. package/src/ssr/RailEditor.tsx +293 -0
  463. package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
  464. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  465. package/src/ssr/admin-active-link.ts +47 -0
  466. package/src/ssr/admin-navigation.ts +78 -0
  467. package/src/ssr/app-appearance.ts +23 -0
  468. package/src/ssr/app-navigation.ts +41 -0
  469. package/src/ssr/help-messages.ts +104 -0
  470. package/src/ssr/index.ts +14 -0
  471. package/src/ssr/islands/SearchBar.island.tsx +80 -0
  472. package/src/ssr/islands/index.ts +16 -0
  473. package/src/ssr/layout-context.ts +17 -0
  474. package/src/ssr/layout-help-markdown.ts +9 -0
  475. package/src/ssr/layout-help-navigation.ts +24 -0
  476. package/src/ssr/layout-help-url.ts +5 -0
  477. package/src/ssr/layout-runtime.ts +22 -0
  478. package/src/ssr/page-error-messages.ts +25 -0
  479. package/src/ssr/platform-messages.ts +135 -0
  480. package/src/ssr/preference-controller.ts +44 -0
  481. package/src/ssr/profile-preferences-messages.ts +29 -0
  482. package/src/ssr/rail-context.ts +21 -0
  483. package/src/ssr/rail-messages.ts +66 -0
  484. package/src/ssr/rail-navigation.ts +60 -0
  485. package/src/ssr/runtime.ts +31 -0
  486. package/src/ssr/url-filter.ts +127 -0
  487. package/src/styles/base-popover.css +36 -0
  488. package/src/styles/effects.css +851 -0
  489. package/src/styles/global.css +268 -0
  490. package/src/styles/input.css +58 -0
  491. package/src/styles/tokens.css +230 -0
  492. package/src/styles/utilities-buttons.css +405 -0
  493. package/src/styles/utilities-data.css +33 -0
  494. package/src/styles/utilities-feedback.css +232 -0
  495. package/src/styles/utilities-layout.css +603 -0
  496. package/src/styles/utilities-markdown-editor.css +525 -0
  497. package/src/styles/utilities-markdown-table.css +234 -0
  498. package/src/styles/utilities-navigation.css +1054 -0
  499. package/src/types/ambient.d.ts +24 -0
  500. package/src/workflows/ai/index.ts +17 -0
  501. package/src/workflows/ai/runtime.ts +286 -0
  502. package/src/workflows/ai/store.ts +314 -0
  503. package/src/workflows/ai/types.ts +111 -0
  504. package/src/workflows/ai-actions.ts +269 -0
  505. package/src/workflows/authoring.ts +88 -0
  506. package/src/workflows/builtins.ts +193 -0
  507. package/src/workflows/contracts.ts +280 -0
  508. package/src/workflows/definition.ts +334 -0
  509. package/src/workflows/editor.ts +113 -0
  510. package/src/workflows/index.ts +71 -0
  511. package/src/workflows/language/binder.ts +45 -0
  512. package/src/workflows/language/canonical.ts +29 -0
  513. package/src/workflows/language/compiler.ts +396 -0
  514. package/src/workflows/language/expressions.ts +34 -0
  515. package/src/workflows/language/index.ts +20 -0
  516. package/src/workflows/language/references.ts +55 -0
  517. package/src/workflows/language/schema.ts +134 -0
  518. package/src/workflows/language/strict-yaml.ts +131 -0
  519. package/src/workflows/manifest.ts +24 -0
  520. package/src/workflows/module.ts +12 -0
  521. package/src/workflows/runtime/coordinator.ts +115 -0
  522. package/src/workflows/runtime/executor.ts +1003 -0
  523. package/src/workflows/runtime/index.ts +60 -0
  524. package/src/workflows/runtime/instant.ts +8 -0
  525. package/src/workflows/runtime/ports.ts +213 -0
  526. package/src/workflows/runtime/schedule.ts +203 -0
  527. package/src/workflows/runtime/trigger.ts +37 -0
  528. package/src/workflows/runtime/wake.ts +13 -0
  529. package/src/workflows/store/actions.ts +438 -0
  530. package/src/workflows/store/budget.ts +145 -0
  531. package/src/workflows/store/definitions.ts +345 -0
  532. package/src/workflows/store/events.ts +383 -0
  533. package/src/workflows/store/index.ts +107 -0
  534. package/src/workflows/store/observability.ts +594 -0
  535. package/src/workflows/store/runs.ts +975 -0
  536. package/src/workflows/store/transaction.ts +23 -0
  537. package/src/workflows/store/worker.ts +439 -0
  538. package/src/workflows/testing/conformance.ts +72 -0
  539. package/src/workflows/testing/index.ts +11 -0
  540. package/src/workflows/testing/process-fixtures.ts +275 -0
@@ -0,0 +1,722 @@
1
+ import { createHash } from "node:crypto";
2
+ import { err, fail, ok, type Result } from "@k2b/stdlib";
3
+ import type { Worker } from "@k2b/sync";
4
+ import { sql } from "bun";
5
+ import { lazySync } from "../../_internal/process-sync";
6
+ import { markdown } from "../../shared/markdown";
7
+ import { logger, trace } from "../logging";
8
+ import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
9
+
10
+ import { sendEmail } from "./email";
11
+
12
+ const log = logger("notifications:batches");
13
+ const CHUNK_SIZE = 100;
14
+ const MAX_PAGE = 10_000;
15
+ const MAX_PER_PAGE = 100;
16
+
17
+ export type NotificationBatchStatus = "draft" | "ready" | "running" | "completed" | "completed_with_errors" | "failed" | "cancelled";
18
+ export type NotificationBatchRecipientStatus = "pending" | "sending" | "sent" | "skipped" | "error";
19
+
20
+ export type NotificationBatchSelection = {
21
+ userIds?: string[];
22
+ groupIds?: string[];
23
+ };
24
+
25
+ export type NotificationBatchPreview = {
26
+ targetCount: number;
27
+ deliverableCount: number;
28
+ skippedNoEmailCount: number;
29
+ duplicateCount: number;
30
+ recipientHash: string;
31
+ };
32
+
33
+ export type NotificationBatch = {
34
+ id: string;
35
+ subject: string;
36
+ bodyMarkdown: string;
37
+ bodyHtml: string;
38
+ selection: NotificationBatchSelection;
39
+ selectionHash: string;
40
+ status: NotificationBatchStatus;
41
+ createdBy: string | null;
42
+ finalizedBy: string | null;
43
+ createdAt: string;
44
+ finalizedAt: string | null;
45
+ startedAt: string | null;
46
+ completedAt: string | null;
47
+ targetCount: number;
48
+ deliverableCount: number;
49
+ sentCount: number;
50
+ skippedCount: number;
51
+ errorCount: number;
52
+ lastError: string | null;
53
+ };
54
+
55
+ export type NotificationBatchRecipient = {
56
+ batchId: string;
57
+ userId: string;
58
+ recipient: string | null;
59
+ uid: string;
60
+ displayName: string;
61
+ avatarHash: string | null;
62
+ provider: "local" | "ipa";
63
+ profile: "user" | "guest";
64
+ status: NotificationBatchRecipientStatus;
65
+ notificationId: string | null;
66
+ error: string | null;
67
+ attemptCount: number;
68
+ sentAt: string | null;
69
+ updatedAt: string;
70
+ };
71
+
72
+ type RecipientCandidate = {
73
+ id: string;
74
+ uid: string;
75
+ display_name: string;
76
+ mail: string | null;
77
+ provider: "local" | "ipa";
78
+ profile: "user" | "guest";
79
+ source_hits: number;
80
+ };
81
+
82
+ type BatchRow = Record<string, unknown>;
83
+
84
+ const normalizeIds = (values: string[] | null | undefined): string[] => [...new Set((values ?? []).filter(Boolean))].sort();
85
+
86
+ const normalizeSelection = (selection: NotificationBatchSelection): NotificationBatchSelection => ({
87
+ userIds: normalizeIds(selection.userIds),
88
+ groupIds: normalizeIds(selection.groupIds),
89
+ });
90
+
91
+ const hasAudience = (selection: NotificationBatchSelection): boolean =>
92
+ (selection.userIds?.length ?? 0) > 0 || (selection.groupIds?.length ?? 0) > 0;
93
+
94
+ const hasLegacyAudienceSelection = (selection: NotificationBatchSelection): boolean => {
95
+ const raw = selection as NotificationBatchSelection & Record<string, unknown>;
96
+ const mode = typeof raw.mode === "string" ? raw.mode : undefined;
97
+ const rules = Array.isArray(raw.rules) ? raw.rules : [];
98
+ const accountManagers = raw.accountManagers as { mode?: string; groupIds?: unknown } | undefined;
99
+ const managerMode = accountManagers && typeof accountManagers.mode === "string" ? accountManagers.mode : undefined;
100
+ const managerGroupIds = Array.isArray(accountManagers?.groupIds) ? accountManagers.groupIds : [];
101
+ const providers = Array.isArray(raw.providers) ? raw.providers : [];
102
+ const profiles = Array.isArray(raw.profiles) ? raw.profiles : [];
103
+ return (
104
+ (mode !== undefined && mode !== "specific") ||
105
+ rules.length > 0 ||
106
+ raw.all === true ||
107
+ raw.includeGroupMembers === false ||
108
+ (managerMode !== undefined && managerMode !== "none") ||
109
+ managerGroupIds.length > 0 ||
110
+ providers.length > 0 ||
111
+ profiles.length > 0
112
+ );
113
+ };
114
+
115
+ const selectionHash = (selection: NotificationBatchSelection): string =>
116
+ createHash("sha256")
117
+ .update(JSON.stringify(normalizeSelection(selection)))
118
+ .digest("hex");
119
+
120
+ const recipientHash = (candidates: RecipientCandidate[]): string =>
121
+ createHash("sha256")
122
+ .update(
123
+ JSON.stringify(
124
+ candidates
125
+ .filter((candidate) => candidate.mail)
126
+ .map((candidate) => candidate.id)
127
+ .sort(),
128
+ ),
129
+ )
130
+ .digest("hex");
131
+
132
+ const mapBatch = (row: BatchRow): NotificationBatch => ({
133
+ id: row.id as string,
134
+ subject: row.subject as string,
135
+ bodyMarkdown: row.body_markdown as string,
136
+ bodyHtml: row.body_html as string,
137
+ selection: (parsePgJsonValue(row.selection) ?? {}) as NotificationBatchSelection,
138
+ selectionHash: row.selection_hash as string,
139
+ status: row.status as NotificationBatchStatus,
140
+ createdBy: row.created_by as string | null,
141
+ finalizedBy: row.finalized_by as string | null,
142
+ createdAt: (row.created_at as Date).toISOString(),
143
+ finalizedAt: row.finalized_at ? (row.finalized_at as Date).toISOString() : null,
144
+ startedAt: row.started_at ? (row.started_at as Date).toISOString() : null,
145
+ completedAt: row.completed_at ? (row.completed_at as Date).toISOString() : null,
146
+ targetCount: Number(row.target_count ?? 0),
147
+ deliverableCount: Number(row.deliverable_count ?? 0),
148
+ sentCount: Number(row.sent_count ?? 0),
149
+ skippedCount: Number(row.skipped_count ?? 0),
150
+ errorCount: Number(row.error_count ?? 0),
151
+ lastError: row.last_error as string | null,
152
+ });
153
+
154
+ const mapRecipient = (row: BatchRow): NotificationBatchRecipient => ({
155
+ batchId: row.batch_id as string,
156
+ userId: row.user_id as string,
157
+ recipient: row.recipient as string | null,
158
+ uid: row.uid as string,
159
+ displayName: row.display_name as string,
160
+ avatarHash: (row.avatar_hash as string | null | undefined) ?? null,
161
+ provider: row.provider as "local" | "ipa",
162
+ profile: row.profile as "user" | "guest",
163
+ status: row.status as NotificationBatchRecipientStatus,
164
+ notificationId: row.notification_id as string | null,
165
+ error: row.error as string | null,
166
+ attemptCount: Number(row.attempt_count ?? 0),
167
+ sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
168
+ updatedAt: (row.updated_at as Date).toISOString(),
169
+ });
170
+
171
+ const resolveCandidates = async (rawSelection: NotificationBatchSelection): Promise<RecipientCandidate[]> => {
172
+ const selection = normalizeSelection(rawSelection);
173
+ const userIds = selection.userIds ?? [];
174
+ const groupIds = selection.groupIds ?? [];
175
+
176
+ if (userIds.length === 0 && groupIds.length === 0) return [];
177
+
178
+ return await sql<RecipientCandidate[]>`
179
+ WITH RECURSIVE
180
+ selected_users(user_id) AS (
181
+ SELECT unnest(${toPgUuidArray(userIds)}::uuid[])
182
+ ),
183
+ selected_groups(group_id) AS (
184
+ SELECT unnest(${toPgUuidArray(groupIds)}::uuid[])
185
+ ),
186
+ group_tree(group_id) AS (
187
+ SELECT group_id FROM selected_groups
188
+ UNION
189
+ SELECT gg.child_group_id
190
+ FROM auth.group_groups_v2 gg
191
+ JOIN group_tree tree ON tree.group_id = gg.parent_group_id
192
+ ),
193
+ candidates(user_id) AS (
194
+ SELECT user_id FROM selected_users
195
+ UNION ALL
196
+ SELECT ug.user_id
197
+ FROM auth.user_groups_v2 ug
198
+ JOIN group_tree tree ON tree.group_id = ug.group_id
199
+ ),
200
+ candidate_counts AS (
201
+ SELECT user_id, COUNT(*)::int AS source_hits
202
+ FROM candidates
203
+ GROUP BY user_id
204
+ )
205
+ SELECT u.id, u.uid, u.display_name, u.mail, u.provider, u.profile, c.source_hits
206
+ FROM candidate_counts c
207
+ JOIN auth.users u ON u.id = c.user_id
208
+ ORDER BY u.uid
209
+ `;
210
+ };
211
+
212
+ const sendBatchEmail = async (params: {
213
+ recipient: string;
214
+ subject: string;
215
+ rawHtml: string;
216
+ sentBy?: string;
217
+ }): Promise<{ id: string; status: "sent" | "error"; error?: string }> => {
218
+ const rows = await sql<BatchRow[]>`
219
+ INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
220
+ VALUES ('email', ${params.recipient}, ${params.subject}, ${params.rawHtml}, ${params.sentBy ?? null})
221
+ RETURNING id
222
+ `;
223
+ const id = rows[0]!.id as string;
224
+ try {
225
+ await sendEmail(params.recipient, params.subject, { rawHtml: params.rawHtml });
226
+ await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}::uuid`;
227
+ return { id, status: "sent" };
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ await sql`UPDATE notifications.messages SET error = ${message} WHERE id = ${id}::uuid`;
231
+ return { id, status: "error", error: message };
232
+ }
233
+ };
234
+
235
+ export const preview = async (selection: NotificationBatchSelection): Promise<NotificationBatchPreview> => {
236
+ const candidates = await resolveCandidates(selection);
237
+ return {
238
+ targetCount: candidates.length,
239
+ deliverableCount: candidates.filter((candidate) => candidate.mail).length,
240
+ skippedNoEmailCount: candidates.filter((candidate) => !candidate.mail).length,
241
+ duplicateCount: candidates.reduce((sum, candidate) => sum + Math.max(0, Number(candidate.source_hits ?? 1) - 1), 0),
242
+ recipientHash: recipientHash(candidates),
243
+ };
244
+ };
245
+
246
+ const refreshBatchCounters = async (batchId: string): Promise<NotificationBatch | null> => {
247
+ const rows = await sql<BatchRow[]>`
248
+ WITH counts AS (
249
+ SELECT
250
+ COUNT(*)::int AS target_count,
251
+ COUNT(*) FILTER (WHERE r.recipient IS NOT NULL)::int AS deliverable_count,
252
+ COUNT(*) FILTER (WHERE r.status = 'sent')::int AS sent_count,
253
+ COUNT(*) FILTER (WHERE r.status = 'skipped')::int AS skipped_count,
254
+ COUNT(*) FILTER (WHERE r.status = 'error')::int AS error_count,
255
+ COUNT(*) FILTER (WHERE r.status IN ('pending', 'sending'))::int AS pending_count,
256
+ MAX(COALESCE(m.error, r.error)) FILTER (WHERE r.status = 'error') AS last_error
257
+ FROM notifications.batch_recipients r
258
+ LEFT JOIN notifications.messages m ON m.id = r.notification_id
259
+ WHERE r.batch_id = ${batchId}::uuid
260
+ )
261
+ UPDATE notifications.batches b
262
+ SET
263
+ target_count = counts.target_count,
264
+ deliverable_count = counts.deliverable_count,
265
+ sent_count = counts.sent_count,
266
+ skipped_count = counts.skipped_count,
267
+ error_count = counts.error_count,
268
+ last_error = counts.last_error,
269
+ status = CASE
270
+ WHEN b.status IN ('draft', 'ready', 'cancelled') THEN b.status
271
+ WHEN counts.pending_count > 0 THEN 'running'
272
+ WHEN counts.error_count > 0 THEN 'completed_with_errors'
273
+ ELSE 'completed'
274
+ END,
275
+ completed_at = CASE
276
+ WHEN b.status IN ('draft', 'ready', 'cancelled') THEN b.completed_at
277
+ WHEN counts.pending_count = 0 AND b.completed_at IS NULL THEN now()
278
+ ELSE b.completed_at
279
+ END
280
+ FROM counts
281
+ WHERE b.id = ${batchId}::uuid
282
+ RETURNING b.*
283
+ `;
284
+ return rows[0] ? mapBatch(rows[0]) : null;
285
+ };
286
+
287
+ const processBatchChunk = async (batchId: string): Promise<{ processed: number; remaining: number }> => {
288
+ await sql`
289
+ UPDATE notifications.batches
290
+ SET status = 'running', started_at = COALESCE(started_at, now())
291
+ WHERE id = ${batchId}::uuid AND status IN ('ready', 'running')
292
+ `;
293
+
294
+ const recipients = await sql<BatchRow[]>`
295
+ UPDATE notifications.batch_recipients r
296
+ SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
297
+ WHERE (r.batch_id, r.user_id) IN (
298
+ SELECT batch_id, user_id
299
+ FROM notifications.batch_recipients
300
+ WHERE batch_id = ${batchId}::uuid
301
+ AND recipient IS NOT NULL
302
+ AND (
303
+ status = 'pending'
304
+ OR (status = 'sending' AND updated_at < now() - interval '5 minutes')
305
+ )
306
+ ORDER BY updated_at ASC
307
+ LIMIT ${CHUNK_SIZE}
308
+ FOR UPDATE SKIP LOCKED
309
+ )
310
+ RETURNING r.*
311
+ `;
312
+
313
+ const [batchRow] = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${batchId}::uuid`;
314
+ if (!batchRow || batchRow.status === "cancelled") return { processed: 0, remaining: 0 };
315
+ const batch = mapBatch(batchRow);
316
+
317
+ for (const recipient of recipients) {
318
+ try {
319
+ const result = await sendBatchEmail({
320
+ recipient: recipient.recipient as string,
321
+ subject: batch.subject,
322
+ rawHtml: batch.bodyHtml,
323
+ sentBy: batch.finalizedBy ?? batch.createdBy ?? undefined,
324
+ });
325
+ await sql`
326
+ UPDATE notifications.batch_recipients
327
+ SET
328
+ status = ${result.status === "sent" ? "sent" : "error"},
329
+ notification_id = ${result.id}::uuid,
330
+ error = NULL,
331
+ sent_at = ${result.status === "sent" ? sql`now()` : null},
332
+ updated_at = now()
333
+ WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
334
+ `;
335
+ } catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ log.error("Batch recipient send failed", { batchId, userId: recipient.user_id, error: message });
338
+ await sql`
339
+ UPDATE notifications.batch_recipients
340
+ SET status = 'error', error = ${message}, updated_at = now()
341
+ WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
342
+ `;
343
+ }
344
+ }
345
+
346
+ const [pendingRow] = await sql<BatchRow[]>`
347
+ SELECT COUNT(*)::int AS count
348
+ FROM notifications.batch_recipients
349
+ WHERE batch_id = ${batchId}::uuid
350
+ AND recipient IS NOT NULL
351
+ AND status IN ('pending', 'sending')
352
+ `;
353
+ const remaining = Number(pendingRow?.count ?? 0);
354
+ await refreshBatchCounters(batchId);
355
+ return { processed: recipients.length, remaining };
356
+ };
357
+
358
+ const batchJob = lazySync((sync) => {
359
+ const handle = sync.job<{ batchId: string }>({
360
+ id: "notifications:batches",
361
+ owner: "core",
362
+ delivery: { ackWaitMs: 180_000, maxAttempts: 4, backoffMs: [1_000, 2_000, 4_000] },
363
+ });
364
+
365
+ return handle;
366
+ });
367
+ let batchWorker: Worker | undefined;
368
+ const startBatchWorker = () =>
369
+ batchJob().process(
370
+ {
371
+ onError: async ({ context, error }) => {
372
+ if (context.attempt < 4) return { action: "retry" };
373
+ const message = error instanceof Error ? error.message : String(error);
374
+ log.error("Notification batch job failed", { batchId: context.input.batchId, attempt: context.attempt, error: message });
375
+ await sql`
376
+ UPDATE notifications.batches
377
+ SET status = 'failed', completed_at = now(), last_error = ${message}
378
+ WHERE id = ${context.input.batchId}::uuid AND status IN ('ready', 'running')
379
+ `;
380
+ return { action: "dead_letter", reason: message };
381
+ },
382
+ },
383
+ async (ctx) => {
384
+ if (ctx.signal.aborted) return;
385
+ const result = await trace.withSpan(
386
+ {
387
+ name: "Notification batch delivery",
388
+ source: "notifications:batches",
389
+ appId: "core",
390
+ category: "job",
391
+ kind: "consumer",
392
+ spanKey: trace.syncSpanKey("job", "notifications:batches", ctx.jobId),
393
+ attributes: { "cloud.notification.batch_id": ctx.input.batchId },
394
+ },
395
+ () => processBatchChunk(ctx.input.batchId),
396
+ { summarize: (summary) => summary },
397
+ );
398
+ await ctx.heartbeat();
399
+ if (result.remaining > 0) await ctx.resubmit({ delayMs: result.processed > 0 ? 0 : 60_000 });
400
+ },
401
+ );
402
+ const submitBatch = async (batchId: string): Promise<string> =>
403
+ (await batchJob().submit({ key: batchId, input: { batchId }, coalesce: true })).jobId;
404
+
405
+ export const createDraft = async (params: {
406
+ subject: string;
407
+ bodyMarkdown: string;
408
+ selection: NotificationBatchSelection;
409
+ createdBy: string;
410
+ }): Promise<Result<NotificationBatch>> => {
411
+ const subject = params.subject.trim();
412
+ const bodyMarkdown = params.bodyMarkdown.trim();
413
+ if (!subject) return fail(err.badInput("Subject is required"));
414
+ if (!bodyMarkdown) return fail(err.badInput("Message is required"));
415
+ const selection = normalizeSelection(params.selection);
416
+ if (!hasAudience(selection)) {
417
+ return fail(err.badInput("Select at least one user or group."));
418
+ }
419
+ const candidates = await resolveCandidates(selection);
420
+ if (!candidates.some((candidate) => candidate.mail)) {
421
+ return fail(err.badInput("No deliverable recipients match this selection"));
422
+ }
423
+ const hash = selectionHash(selection);
424
+ const bodyHtml = markdown.renderSync(bodyMarkdown);
425
+ const rows = await sql<BatchRow[]>`
426
+ INSERT INTO notifications.batches (subject, body_markdown, body_html, selection, selection_hash, created_by)
427
+ VALUES (${subject}, ${bodyMarkdown}, ${bodyHtml}, ${JSON.stringify(selection)}::jsonb, ${hash}, ${params.createdBy}::uuid)
428
+ RETURNING *
429
+ `;
430
+ return ok(mapBatch(rows[0]!));
431
+ };
432
+
433
+ export const get = async (id: string): Promise<NotificationBatch | null> => {
434
+ const rows = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${id}::uuid`;
435
+ return rows[0] ? mapBatch(rows[0]) : null;
436
+ };
437
+
438
+ export const list = async (params?: {
439
+ page?: number;
440
+ perPage?: number;
441
+ status?: NotificationBatchStatus;
442
+ }): Promise<{ items: NotificationBatch[]; total: number; page: number; perPage: number }> => {
443
+ const page = Math.min(Math.max(params?.page ?? 1, 1), MAX_PAGE);
444
+ const perPage = Math.min(Math.max(params?.perPage ?? 50, 1), MAX_PER_PAGE);
445
+ const offset = (page - 1) * perPage;
446
+ const rows = await sql<BatchRow[]>`
447
+ SELECT *, COUNT(*) OVER() AS total
448
+ FROM notifications.batches
449
+ WHERE (${params?.status ?? null}::text IS NULL OR status = ${params?.status ?? null})
450
+ ORDER BY created_at DESC
451
+ LIMIT ${perPage} OFFSET ${offset}
452
+ `;
453
+ return {
454
+ items: rows.map(mapBatch),
455
+ total: Number(rows[0]?.total ?? 0),
456
+ page,
457
+ perPage,
458
+ };
459
+ };
460
+
461
+ export const listRecipients = async (params: {
462
+ batchId: string;
463
+ page?: number;
464
+ perPage?: number;
465
+ status?: NotificationBatchRecipientStatus;
466
+ }): Promise<{ items: NotificationBatchRecipient[]; total: number; page: number; perPage: number }> => {
467
+ const page = Math.min(Math.max(params.page ?? 1, 1), MAX_PAGE);
468
+ const perPage = Math.min(Math.max(params.perPage ?? 100, 1), MAX_PER_PAGE);
469
+ const offset = (page - 1) * perPage;
470
+ const rows = await sql<BatchRow[]>`
471
+ SELECT
472
+ r.batch_id,
473
+ r.user_id,
474
+ r.recipient,
475
+ r.uid,
476
+ r.display_name,
477
+ u.avatar_hash,
478
+ r.provider,
479
+ r.profile,
480
+ r.status,
481
+ r.notification_id,
482
+ COALESCE(m.error, r.error) AS error,
483
+ r.attempt_count,
484
+ r.sent_at,
485
+ r.updated_at,
486
+ COUNT(*) OVER() AS total
487
+ FROM notifications.batch_recipients r
488
+ LEFT JOIN notifications.messages m ON m.id = r.notification_id
489
+ LEFT JOIN auth.users u ON u.id = r.user_id
490
+ WHERE r.batch_id = ${params.batchId}::uuid
491
+ AND (${params.status ?? null}::text IS NULL OR r.status = ${params.status ?? null})
492
+ ORDER BY
493
+ CASE r.status WHEN 'error' THEN 0 WHEN 'pending' THEN 1 WHEN 'sending' THEN 2 WHEN 'skipped' THEN 3 ELSE 4 END,
494
+ r.uid
495
+ LIMIT ${perPage} OFFSET ${offset}
496
+ `;
497
+ return {
498
+ items: rows.map(mapRecipient),
499
+ total: Number(rows[0]?.total ?? 0),
500
+ page,
501
+ perPage,
502
+ };
503
+ };
504
+
505
+ export const finalize = async (params: {
506
+ id: string;
507
+ actorUserId: string;
508
+ expectedSelectionHash: string;
509
+ expectedDeliverableCount: number;
510
+ expectedRecipientHash: string;
511
+ }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
512
+ const batch = await get(params.id);
513
+ if (!batch) return fail(err.notFound("Notification batch not found"));
514
+ if (hasLegacyAudienceSelection(batch.selection)) {
515
+ return fail(err.badInput("Legacy notification drafts cannot be finalized. Create a new notification batch."));
516
+ }
517
+
518
+ const candidates = await resolveCandidates(batch.selection);
519
+ const deliverableCount = candidates.filter((candidate) => candidate.mail).length;
520
+ const currentRecipientHash = recipientHash(candidates);
521
+ if (deliverableCount === 0) {
522
+ return fail(err.badInput("No deliverable recipients match this batch"));
523
+ }
524
+ const result = await sql.begin(async (tx): Promise<Result<NotificationBatch>> => {
525
+ const rows = await tx<BatchRow[]>`
526
+ SELECT * FROM notifications.batches WHERE id = ${params.id}::uuid FOR UPDATE
527
+ `;
528
+ const batchRow = rows[0];
529
+ if (!batchRow) return fail(err.notFound("Notification batch not found"));
530
+ if (batchRow.status !== "draft") return fail(err.conflict("Notification batch has already been finalized"));
531
+ if (batchRow.selection_hash !== params.expectedSelectionHash)
532
+ return fail(err.conflict("Notification selection changed. Refresh the preview."));
533
+ if (deliverableCount !== params.expectedDeliverableCount) {
534
+ return fail(err.conflict("Recipient count changed. Refresh the preview before sending."));
535
+ }
536
+ if (currentRecipientHash !== params.expectedRecipientHash) {
537
+ return fail(err.conflict("Recipient list changed. Refresh the preview before sending."));
538
+ }
539
+
540
+ await tx`
541
+ INSERT INTO notifications.batch_recipients (
542
+ batch_id, user_id, recipient, uid, display_name, provider, profile, status, error
543
+ )
544
+ SELECT
545
+ ${params.id}::uuid,
546
+ row.user_id,
547
+ NULLIF(row.recipient, ''),
548
+ row.uid,
549
+ row.display_name,
550
+ row.provider,
551
+ row.profile,
552
+ row.status,
553
+ NULLIF(row.error, '')
554
+ FROM unnest(
555
+ ${toPgUuidArray(candidates.map((candidate) => candidate.id))}::uuid[],
556
+ ${toPgTextArray(candidates.map((candidate) => candidate.mail ?? ""))}::text[],
557
+ ${toPgTextArray(candidates.map((candidate) => candidate.uid))}::text[],
558
+ ${toPgTextArray(candidates.map((candidate) => candidate.display_name ?? ""))}::text[],
559
+ ${toPgTextArray(candidates.map((candidate) => candidate.provider))}::text[],
560
+ ${toPgTextArray(candidates.map((candidate) => candidate.profile))}::text[],
561
+ ${toPgTextArray(candidates.map((candidate) => (candidate.mail ? "pending" : "skipped")))}::text[],
562
+ ${toPgTextArray(candidates.map((candidate) => (candidate.mail ? "" : "User has no email address")))}::text[]
563
+ ) AS row(user_id, recipient, uid, display_name, provider, profile, status, error)
564
+ ON CONFLICT (batch_id, user_id) DO NOTHING
565
+ `;
566
+
567
+ const updated = await tx<BatchRow[]>`
568
+ UPDATE notifications.batches
569
+ SET
570
+ status = 'ready',
571
+ finalized_by = ${params.actorUserId}::uuid,
572
+ finalized_at = now(),
573
+ target_count = ${candidates.length},
574
+ deliverable_count = ${deliverableCount},
575
+ skipped_count = ${candidates.length - deliverableCount}
576
+ WHERE id = ${params.id}::uuid
577
+ RETURNING *
578
+ `;
579
+ return ok(mapBatch(updated[0]!));
580
+ });
581
+
582
+ if (!result.ok) return result;
583
+ const jobId = await submitBatch(params.id);
584
+ return ok({ batch: result.data, jobId });
585
+ };
586
+
587
+ export const retryFailed = async (params: { id: string }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
588
+ const batch = await get(params.id);
589
+ if (!batch) return fail(err.notFound("Notification batch not found"));
590
+ if (batch.status === "draft" || batch.status === "cancelled") {
591
+ return fail(err.conflict("Only finalized notification batches can retry recipients"));
592
+ }
593
+
594
+ const updated = await sql<BatchRow[]>`
595
+ UPDATE notifications.batch_recipients
596
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
597
+ WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
598
+ RETURNING user_id
599
+ `;
600
+ if (updated.length === 0) {
601
+ return fail(err.conflict("No failed deliverable recipients can be retried"));
602
+ }
603
+
604
+ await sql`
605
+ UPDATE notifications.batches
606
+ SET status = 'ready', completed_at = NULL, last_error = NULL
607
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
608
+ `;
609
+ const refreshed = await refreshBatchCounters(params.id);
610
+ // A completing worker may already have sampled zero remaining recipients.
611
+ // Explicit retries need their own wakeup even while that worker holds its key.
612
+ const { jobId } = await batchJob().submit({ key: `${params.id}:retry:${crypto.randomUUID()}`, input: { batchId: params.id } });
613
+ return ok({ batch: refreshed ?? batch, jobId });
614
+ };
615
+
616
+ export const retryRecipient = async (params: {
617
+ id: string;
618
+ userId: string;
619
+ }): Promise<Result<{ batch: NotificationBatch; jobId: string }>> => {
620
+ const batch = await get(params.id);
621
+ if (!batch) return fail(err.notFound("Notification batch not found"));
622
+ if (batch.status === "draft" || batch.status === "cancelled") {
623
+ return fail(err.conflict("Only finalized notification batches can retry recipients"));
624
+ }
625
+
626
+ const updated = await sql<BatchRow[]>`
627
+ UPDATE notifications.batch_recipients
628
+ SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
629
+ WHERE batch_id = ${params.id}::uuid
630
+ AND user_id = ${params.userId}::uuid
631
+ AND status = 'error'
632
+ AND recipient IS NOT NULL
633
+ RETURNING *
634
+ `;
635
+ if (!updated[0]) {
636
+ const existing = await sql<BatchRow[]>`
637
+ SELECT status, recipient
638
+ FROM notifications.batch_recipients
639
+ WHERE batch_id = ${params.id}::uuid AND user_id = ${params.userId}::uuid
640
+ `;
641
+ if (!existing[0]) return fail(err.notFound("Notification recipient not found"));
642
+ return fail(err.conflict("Only failed deliverable recipients can be retried"));
643
+ }
644
+
645
+ await sql`
646
+ UPDATE notifications.batches
647
+ SET status = 'ready', completed_at = NULL, last_error = NULL
648
+ WHERE id = ${params.id}::uuid AND status IN ('completed_with_errors', 'failed', 'running', 'ready', 'completed')
649
+ `;
650
+ const refreshed = await refreshBatchCounters(params.id);
651
+ const { jobId } = await batchJob().submit({
652
+ key: `${params.id}:retry:${crypto.randomUUID()}`,
653
+ input: { batchId: params.id },
654
+ });
655
+ return ok({ batch: refreshed ?? batch, jobId });
656
+ };
657
+
658
+ export const removeDraft = async (params: { id: string }): Promise<Result<{ id: string }>> => {
659
+ const rows = await sql<BatchRow[]>`
660
+ DELETE FROM notifications.batches
661
+ WHERE id = ${params.id}::uuid AND status = 'draft'
662
+ RETURNING id
663
+ `;
664
+ if (rows[0]) return ok({ id: rows[0].id as string });
665
+
666
+ const batch = await get(params.id);
667
+ if (!batch) return fail(err.notFound("Notification batch not found"));
668
+ return fail(err.conflict("Only draft notification batches can be deleted"));
669
+ };
670
+
671
+ export const start = async (): Promise<void> => {
672
+ if (batchWorker) return;
673
+ batchWorker = await startBatchWorker();
674
+ try {
675
+ // Postgres owns the batch state; recover unfinished work across cutover and
676
+ // restarts with keyset pages rather than an unbounded startup scan.
677
+ let after: string | null = null;
678
+ while (true) {
679
+ const pending: { id: string }[] = await sql`
680
+ SELECT id FROM notifications.batches
681
+ WHERE status IN ('ready', 'running') AND (${after}::uuid IS NULL OR id > ${after}::uuid)
682
+ ORDER BY id LIMIT ${CHUNK_SIZE}
683
+ `;
684
+ for (const batch of pending) await submitBatch(batch.id);
685
+ if (pending.length < CHUNK_SIZE) break;
686
+ after = pending[pending.length - 1]!.id;
687
+ }
688
+ } catch (error) {
689
+ batchWorker.stop();
690
+ await batchWorker.drain();
691
+ batchWorker = undefined;
692
+ throw error;
693
+ }
694
+ };
695
+
696
+ export const stop = async (): Promise<void> => {
697
+ batchWorker?.stop();
698
+ await batchWorker?.drain();
699
+ batchWorker = undefined;
700
+ };
701
+
702
+ export const notificationBatches = {
703
+ createDraft,
704
+ preview,
705
+ get,
706
+ list,
707
+ listRecipients,
708
+ finalize,
709
+ retryFailed,
710
+ retryRecipient,
711
+ removeDraft,
712
+ start,
713
+ stop,
714
+ };
715
+
716
+ export const __notificationBatchTest = {
717
+ normalizeSelection,
718
+ hasLegacyAudienceSelection,
719
+ selectionHash,
720
+ recipientHash,
721
+ resolveCandidates,
722
+ };