@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,952 @@
1
+ import type { Message } from "@k2b/nessi";
2
+ import { type SQL, sql } from "bun";
3
+ import { logger } from "../services/logging";
4
+ import {
5
+ createMandate,
6
+ type Mandate,
7
+ type MandatePolicyV1,
8
+ MandatePolicyV1Schema,
9
+ pauseMandate,
10
+ resumeMandate,
11
+ revokeMandate,
12
+ } from "../services/mandates";
13
+ import { parsePgJsonValue } from "../services/postgres";
14
+ import { withAiShortIdForDb } from "./short-id";
15
+ import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
16
+
17
+ export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
18
+ export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
19
+ export type AiChatTask = {
20
+ id: string;
21
+ shortId: string;
22
+ chatId: string;
23
+ chatTitle: string;
24
+ conversationId: string;
25
+ sponsorUserId: string;
26
+ mandateId: string | null;
27
+ mandateRevision: number | null;
28
+ prompt: string;
29
+ schedule: AiChatTaskSchedule;
30
+ timezone: string;
31
+ state: AiChatTaskState;
32
+ revision: number;
33
+ lastError: string | null;
34
+ createdAt: string;
35
+ updatedAt: string;
36
+ };
37
+
38
+ export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed";
39
+ export type AiChatTaskOccurrence = {
40
+ id: string;
41
+ shortId: string;
42
+ taskId: string;
43
+ scheduledFor: string;
44
+ trigger: "scheduled" | "manual";
45
+ state: AiChatTaskOccurrenceState;
46
+ turnId: string | null;
47
+ error: string | null;
48
+ createdAt: string;
49
+ startedAt: string | null;
50
+ completedAt: string | null;
51
+ };
52
+
53
+ type TaskRow = {
54
+ id: string;
55
+ short_id: string;
56
+ conversation_id: string;
57
+ conversation_short_id: string;
58
+ conversation_title: string;
59
+ sponsor_user_id: string;
60
+ mandate_id: string | null;
61
+ mandate_revision: number | bigint | null;
62
+ prompt: string;
63
+ schedule_kind: "once" | "cron";
64
+ run_at: Date | string | null;
65
+ cron: string | null;
66
+ timezone: string;
67
+ state: AiChatTaskState;
68
+ revision: number | bigint;
69
+ last_error: string | null;
70
+ created_at: Date | string;
71
+ updated_at: Date | string;
72
+ };
73
+
74
+ export class AiChatTaskIdempotencyConflictError extends Error {
75
+ constructor() {
76
+ super("The idempotency key was already used with different task input");
77
+ this.name = "AiChatTaskIdempotencyConflictError";
78
+ }
79
+ }
80
+
81
+ class AiChatTaskCreateRace extends Error {}
82
+ class AiChatTaskAuthorityError extends Error {}
83
+ const log = logger("ai:chat-tasks");
84
+
85
+ type OccurrenceRow = {
86
+ id: string;
87
+ short_id: string;
88
+ task_id: string;
89
+ scheduled_for: Date | string;
90
+ trigger: "scheduled" | "manual";
91
+ state: AiChatTaskOccurrenceState;
92
+ turn_id: string | null;
93
+ error: string | null;
94
+ created_at: Date | string;
95
+ started_at: Date | string | null;
96
+ completed_at: Date | string | null;
97
+ };
98
+
99
+ const iso = (value: Date | string): string => new Date(value).toISOString();
100
+ const nullableIso = (value: Date | string | null): string | null => (value === null ? null : iso(value));
101
+ const taskIdempotencyKey = (key: string): string => `task.create:${key}`;
102
+ const CHAT_TASK_MANDATE_POLICY = {
103
+ version: 1,
104
+ apps: "*",
105
+ operations: "*",
106
+ actions: "require_approval",
107
+ } as const satisfies MandatePolicyV1;
108
+ const mandateAuthority = (userId: string) => ({ kind: "interactive" as const, userId });
109
+ const mandateWorkloadAuthority = { kind: "workload" as const, ownerAppId: "core" };
110
+ const mandateError = (operation: string, result: { ok: false; error: { message: string } }): Error =>
111
+ new AiChatTaskAuthorityError(`Could not ${operation} scheduled task mandate: ${result.error.message}`);
112
+ const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean =>
113
+ policy.version === CHAT_TASK_MANDATE_POLICY.version &&
114
+ policy.apps === CHAT_TASK_MANDATE_POLICY.apps &&
115
+ policy.operations === CHAT_TASK_MANDATE_POLICY.operations &&
116
+ policy.actions === CHAT_TASK_MANDATE_POLICY.actions;
117
+ const mandateRevision = async (
118
+ task: { mandate_id: string; mandate_revision: number | bigint },
119
+ state: "active" | "paused",
120
+ userId: string,
121
+ db: SQL,
122
+ ): Promise<number> => {
123
+ const input = {
124
+ mandateId: task.mandate_id,
125
+ expectedRevision: Number(task.mandate_revision),
126
+ authority: mandateAuthority(userId),
127
+ };
128
+ const result = state === "active" ? await resumeMandate(input, { db }) : await pauseMandate(input, { db });
129
+ if (!result.ok) throw mandateError(state === "active" ? "resume" : "pause", result);
130
+ return result.data.revision;
131
+ };
132
+ const requireTaskMandate = (
133
+ task: Pick<TaskRow, "mandate_id" | "mandate_revision">,
134
+ ): { mandate_id: string; mandate_revision: number | bigint } => {
135
+ if (!task.mandate_id || task.mandate_revision === null)
136
+ throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable; recreate the task");
137
+ return { mandate_id: task.mandate_id, mandate_revision: task.mandate_revision };
138
+ };
139
+ type TaskMandateRow = {
140
+ id: string;
141
+ revision: number | bigint;
142
+ state: Mandate["state"];
143
+ subject_kind: string;
144
+ subject_user_id: string | null;
145
+ owner_app_id: string;
146
+ workload_type: string;
147
+ workload_id: string;
148
+ confirmed_at: Date | string | null;
149
+ unexpired: boolean;
150
+ policy: unknown;
151
+ };
152
+ const loadTaskMandate = async (task: TaskRow, db: SQL): Promise<TaskMandateRow | null> => {
153
+ if (!task.mandate_id) return null;
154
+ const [mandate] = await db<TaskMandateRow[]>`
155
+ SELECT id, revision, state, subject_kind, subject_user_id, owner_app_id, workload_type, workload_id,
156
+ confirmed_at, (expires_at IS NULL OR expires_at > now()) AS unexpired, policy
157
+ FROM auth.mandates WHERE id = ${task.mandate_id}::uuid
158
+ FOR UPDATE
159
+ `;
160
+ return mandate ?? null;
161
+ };
162
+ const taskMandateMatches = (task: TaskRow, mandate: TaskMandateRow): boolean =>
163
+ mandate.subject_kind === "user" &&
164
+ mandate.subject_user_id === task.sponsor_user_id &&
165
+ mandate.owner_app_id === "core" &&
166
+ mandate.workload_type === "ai.chat-task" &&
167
+ mandate.workload_id === task.id;
168
+ const taskMandatePolicy = (mandate: TaskMandateRow): MandatePolicyV1 | null => {
169
+ try {
170
+ const policy = MandatePolicyV1Schema.safeParse(parsePgJsonValue(mandate.policy));
171
+ return policy.success ? policy.data : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ };
176
+ const stopTaskAdmission = async (
177
+ task: TaskRow,
178
+ state: "paused" | "needs_attention",
179
+ reason: string | null,
180
+ db: SQL,
181
+ pausedMandateRevision?: number,
182
+ ): Promise<null> => {
183
+ await db`
184
+ UPDATE ai.chat_tasks
185
+ SET state = ${state}, last_error = ${reason},
186
+ mandate_revision = COALESCE(${pausedMandateRevision ?? null}::bigint, mandate_revision),
187
+ revision = revision + 1, updated_at = now()
188
+ WHERE id = ${task.id}::uuid AND state = 'active'
189
+ `;
190
+ return null;
191
+ };
192
+ /** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
193
+ const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow | null> => {
194
+ if (input.state !== "active") return null;
195
+ const task = input;
196
+ const mandate = await loadTaskMandate(task, db);
197
+ const policy = mandate ? taskMandatePolicy(mandate) : null;
198
+ const [sponsor] = await db<{ id: string }[]>`
199
+ SELECT sponsor.id FROM auth.users sponsor
200
+ JOIN ai.conversations conversation ON conversation.created_by_user_id = sponsor.id
201
+ WHERE sponsor.id = ${task.sponsor_user_id}::uuid AND conversation.id = ${task.conversation_id}::uuid
202
+ AND (sponsor.account_expires IS NULL OR sponsor.account_expires > now()) AND conversation.archived_at IS NULL
203
+ FOR SHARE OF sponsor
204
+ FOR UPDATE OF conversation
205
+ `;
206
+ if (
207
+ !sponsor ||
208
+ !mandate ||
209
+ !taskMandateMatches(task, mandate) ||
210
+ !mandate.confirmed_at ||
211
+ !mandate.unexpired ||
212
+ !policy ||
213
+ !isChatTaskMandatePolicy(policy) ||
214
+ mandate.state === "revoked"
215
+ ) {
216
+ return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate or sponsor is unavailable", db);
217
+ }
218
+ if (mandate.state === "paused") return stopTaskAdmission(task, "paused", null, db, Number(mandate.revision));
219
+ if (task.mandate_revision === null || Number(task.mandate_revision) !== Number(mandate.revision)) {
220
+ return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate revision changed", db);
221
+ }
222
+ return task;
223
+ };
224
+ /** Terminal accounting may narrow existing authority, but must never create or resume it. */
225
+ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number | null> => {
226
+ const mandate = await loadTaskMandate(task, db);
227
+ if (!mandate || !taskMandateMatches(task, mandate)) return task.mandate_revision === null ? null : Number(task.mandate_revision);
228
+ if (mandate.state !== "active" || !taskMandatePolicy(mandate)) return Number(mandate.revision);
229
+ const paused = await pauseMandate(
230
+ { mandateId: mandate.id, expectedRevision: Number(mandate.revision), authority: mandateWorkloadAuthority },
231
+ { db },
232
+ );
233
+ if (!paused.ok) throw mandateError("pause", paused);
234
+ return paused.data.revision;
235
+ };
236
+ const taskFingerprint = (input: { chatId: string; prompt: string; schedule: AiChatTaskSchedule; timezone: string }): string =>
237
+ new Bun.CryptoHasher("sha256").update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone])).digest("hex");
238
+ const taskSelect = sql`
239
+ SELECT task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
240
+ FROM ai.chat_tasks task
241
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
242
+ `;
243
+
244
+ const toTask = (row: TaskRow): AiChatTask => ({
245
+ id: row.id,
246
+ shortId: row.short_id,
247
+ chatId: row.conversation_short_id,
248
+ chatTitle: row.conversation_title,
249
+ conversationId: row.conversation_id,
250
+ sponsorUserId: row.sponsor_user_id,
251
+ mandateId: row.mandate_id,
252
+ mandateRevision: row.mandate_revision === null ? null : Number(row.mandate_revision),
253
+ prompt: row.prompt,
254
+ schedule: row.schedule_kind === "once" ? { kind: "once", runAt: iso(row.run_at!) } : { kind: "cron", cron: row.cron! },
255
+ timezone: row.timezone,
256
+ state: row.state,
257
+ revision: Number(row.revision),
258
+ lastError: row.last_error,
259
+ createdAt: iso(row.created_at),
260
+ updatedAt: iso(row.updated_at),
261
+ });
262
+
263
+ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
264
+ id: row.id,
265
+ shortId: row.short_id,
266
+ taskId: row.task_id,
267
+ scheduledFor: iso(row.scheduled_for),
268
+ trigger: row.trigger,
269
+ state: row.state,
270
+ turnId: row.turn_id,
271
+ error: row.error,
272
+ createdAt: iso(row.created_at),
273
+ startedAt: nullableIso(row.started_at),
274
+ completedAt: nullableIso(row.completed_at),
275
+ });
276
+
277
+ const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
278
+ sql.begin(async (tx) => {
279
+ const [task] = await tx<TaskRow[]>`
280
+ ${taskSelect}
281
+ WHERE task.id = ${taskId}::uuid
282
+ FOR UPDATE OF task
283
+ `;
284
+ const prepared = task ? await prepareTaskForExecution(task, tx) : null;
285
+ return prepared ? toTask(prepared) : null;
286
+ });
287
+ const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
288
+ const [task] = await db<TaskRow[]>`
289
+ ${taskSelect}
290
+ WHERE EXISTS (
291
+ SELECT 1 FROM ai.chat_task_occurrences occurrence
292
+ WHERE occurrence.id = ${occurrenceId}::uuid AND occurrence.task_id = task.id
293
+ )
294
+ FOR UPDATE OF task
295
+ `;
296
+ return task ?? null;
297
+ };
298
+ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> => {
299
+ const [task] = await db<TaskRow[]>`
300
+ ${taskSelect}
301
+ WHERE EXISTS (
302
+ SELECT 1 FROM ai.chat_task_occurrences occurrence
303
+ WHERE occurrence.turn_id = ${turnId}::uuid AND occurrence.task_id = task.id
304
+ )
305
+ FOR UPDATE OF task
306
+ `;
307
+ return task ?? null;
308
+ };
309
+
310
+ export const aiChatTasks = {
311
+ list: async (input: {
312
+ userId: string;
313
+ chatId?: string;
314
+ state?: AiChatTaskState;
315
+ limit?: number;
316
+ offset?: number;
317
+ }): Promise<AiChatTask[]> => {
318
+ const rows = await sql<TaskRow[]>`
319
+ ${taskSelect}
320
+ WHERE task.sponsor_user_id = ${input.userId}::uuid
321
+ AND (${input.chatId ?? null}::text IS NULL OR conversation.short_id = ${input.chatId ?? null})
322
+ AND (${input.state ?? null}::text IS NULL OR task.state = ${input.state ?? null})
323
+ ORDER BY task.created_at DESC, task.id DESC
324
+ LIMIT ${Math.min(Math.max(input.limit ?? 50, 1), 100)}
325
+ OFFSET ${Math.max(input.offset ?? 0, 0)}
326
+ `;
327
+ return rows.map(toTask);
328
+ },
329
+
330
+ get: async (input: { userId: string; taskId: string }): Promise<AiChatTask | null> => {
331
+ const rows = await sql<TaskRow[]>`
332
+ ${taskSelect}
333
+ WHERE task.sponsor_user_id = ${input.userId}::uuid
334
+ AND task.short_id = ${input.taskId}
335
+ LIMIT 1
336
+ `;
337
+ return rows[0] ? toTask(rows[0]) : null;
338
+ },
339
+
340
+ create: async (input: {
341
+ userId: string;
342
+ chatId: string;
343
+ prompt: string;
344
+ schedule: AiChatTaskSchedule;
345
+ timezone: string;
346
+ idempotencyKey?: string;
347
+ idempotencyFingerprint?: string;
348
+ }): Promise<AiChatTask | null> => {
349
+ const scopedKey = input.idempotencyKey ? taskIdempotencyKey(input.idempotencyKey) : null;
350
+ const fingerprint = scopedKey ? (input.idempotencyFingerprint ?? taskFingerprint(input)) : null;
351
+ if (input.idempotencyKey) {
352
+ const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
353
+ ${taskSelect}
354
+ WHERE task.idempotency_key = ${scopedKey}
355
+ AND task.sponsor_user_id = ${input.userId}::uuid
356
+ LIMIT 1
357
+ `;
358
+ if (existing[0]) {
359
+ if (existing[0].idempotency_fingerprint !== fingerprint) throw new AiChatTaskIdempotencyConflictError();
360
+ return toTask(existing[0]);
361
+ }
362
+ }
363
+ let rows: TaskRow[];
364
+ try {
365
+ rows = await sql.begin(async (tx) => {
366
+ const conversations = await tx<{ id: string; title: string }[]>`
367
+ SELECT id, title FROM ai.conversations
368
+ WHERE short_id = ${input.chatId}
369
+ AND created_by_user_id = ${input.userId}::uuid
370
+ AND archived_at IS NULL
371
+ LIMIT 1
372
+ `;
373
+ const conversation = conversations[0];
374
+ if (!conversation) return [];
375
+ const taskId = crypto.randomUUID();
376
+ const mandate = await createMandate(
377
+ {
378
+ authority: mandateAuthority(input.userId),
379
+ subject: { type: "user", id: input.userId },
380
+ ownerAppId: "core",
381
+ workloadType: "ai.chat-task",
382
+ workloadId: taskId,
383
+ policy: CHAT_TASK_MANDATE_POLICY,
384
+ },
385
+ { db: tx },
386
+ );
387
+ if (!mandate.ok) throw mandateError("create", mandate);
388
+ const inserted = await withAiShortIdForDb(
389
+ tx,
390
+ "ai_chat_tasks_short_id_unique",
391
+ (attempt, shortId) => attempt<TaskRow[]>`
392
+ INSERT INTO ai.chat_tasks (
393
+ id, short_id, conversation_id, sponsor_user_id, prompt, schedule_kind, run_at, cron, timezone,
394
+ mandate_id, mandate_revision, idempotency_key, idempotency_fingerprint
395
+ )
396
+ VALUES (
397
+ ${taskId}::uuid, ${shortId}, ${conversation.id}::uuid, ${input.userId}::uuid, ${input.prompt.trim()}, ${input.schedule.kind},
398
+ ${input.schedule.kind === "once" ? input.schedule.runAt : null}::timestamptz,
399
+ ${input.schedule.kind === "cron" ? input.schedule.cron : null}, ${input.timezone},
400
+ ${mandate.data.id}::uuid, ${mandate.data.revision}, ${scopedKey}, ${fingerprint}
401
+ )
402
+ ON CONFLICT (sponsor_user_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
403
+ RETURNING *, ${input.chatId} AS conversation_short_id, ${conversation.title} AS conversation_title
404
+ `,
405
+ );
406
+ const task = inserted[0];
407
+ if (!task) throw new AiChatTaskCreateRace();
408
+ return inserted;
409
+ });
410
+ } catch (error) {
411
+ if (!(error instanceof AiChatTaskCreateRace)) throw error;
412
+ rows = [];
413
+ }
414
+ if (rows[0]) return toTask(rows[0]);
415
+ if (!input.idempotencyKey) return null;
416
+ const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
417
+ ${taskSelect}
418
+ WHERE task.idempotency_key = ${scopedKey}
419
+ AND task.sponsor_user_id = ${input.userId}::uuid
420
+ LIMIT 1
421
+ `;
422
+ if (!existing[0]) return null;
423
+ if (existing[0].idempotency_fingerprint !== fingerprint) throw new AiChatTaskIdempotencyConflictError();
424
+ return toTask(existing[0]);
425
+ },
426
+
427
+ getCreateByIdempotency: async (input: {
428
+ userId: string;
429
+ idempotencyKey: string;
430
+ idempotencyFingerprint: string;
431
+ }): Promise<AiChatTask | null> => {
432
+ const rows = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
433
+ ${taskSelect}
434
+ WHERE task.idempotency_key = ${taskIdempotencyKey(input.idempotencyKey)}
435
+ AND task.sponsor_user_id = ${input.userId}::uuid
436
+ LIMIT 1
437
+ `;
438
+ if (!rows[0]) return null;
439
+ if (rows[0].idempotency_fingerprint !== input.idempotencyFingerprint) throw new AiChatTaskIdempotencyConflictError();
440
+ return toTask(rows[0]);
441
+ },
442
+
443
+ update: async (input: {
444
+ userId: string;
445
+ taskId: string;
446
+ prompt?: string;
447
+ schedule?: AiChatTaskSchedule;
448
+ timezone?: string;
449
+ }): Promise<AiChatTask | null> =>
450
+ sql.begin(async (tx) => {
451
+ const rows = await tx<TaskRow[]>`
452
+ ${taskSelect}
453
+ WHERE task.sponsor_user_id = ${input.userId}::uuid
454
+ AND task.short_id = ${input.taskId}
455
+ FOR UPDATE OF task
456
+ `;
457
+ let current = rows[0];
458
+ if (!current) return null;
459
+ requireTaskMandate(current);
460
+ const wasActive = current.state === "active";
461
+ const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
462
+ if (authorityStopped) {
463
+ [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
464
+ if (!current) return null;
465
+ }
466
+ const nextTimezone = input.timezone ?? current.timezone;
467
+ const scheduleChanged = input.schedule
468
+ ? input.schedule.kind !== current.schedule_kind ||
469
+ (input.schedule.kind === "once" ? iso(input.schedule.runAt) !== iso(current.run_at!) : input.schedule.cron !== current.cron) ||
470
+ nextTimezone !== current.timezone
471
+ : false;
472
+ const promptChanged = input.prompt !== undefined && input.prompt.trim() !== current.prompt;
473
+ if (!promptChanged && !scheduleChanged) return toTask(current);
474
+ await tx`
475
+ UPDATE ai.chat_tasks task
476
+ SET prompt = ${input.prompt?.trim() ?? current.prompt},
477
+ schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
478
+ run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
479
+ cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
480
+ timezone = ${input.timezone ?? current.timezone},
481
+ state = CASE
482
+ WHEN ${authorityStopped} THEN task.state
483
+ WHEN task.state = 'paused' THEN 'paused'
484
+ WHEN task.state = 'completed' AND ${scheduleChanged} = false THEN 'completed'
485
+ ELSE 'active'
486
+ END,
487
+ last_error = CASE
488
+ WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false)
489
+ THEN task.last_error
490
+ ELSE NULL
491
+ END,
492
+ revision = task.revision + 1, updated_at = now()
493
+ WHERE id = ${current.id}::uuid
494
+ `;
495
+ if (scheduleChanged) {
496
+ await tx`
497
+ UPDATE ai.chat_task_occurrences
498
+ SET state = 'failed', error = 'Task schedule changed', completed_at = now()
499
+ WHERE task_id = ${current.id}::uuid AND state = 'queued'
500
+ `;
501
+ }
502
+ const updated = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
503
+ const next = updated[0]!;
504
+ if (next.state === "active" && !wasActive) {
505
+ const revision = await mandateRevision(requireTaskMandate(next), "active", input.userId, tx);
506
+ if (revision !== Number(next.mandate_revision)) {
507
+ next.mandate_revision = revision;
508
+ await tx`UPDATE ai.chat_tasks SET mandate_revision = ${revision} WHERE id = ${next.id}::uuid`;
509
+ }
510
+ }
511
+ return toTask(next);
512
+ }),
513
+
514
+ setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
515
+ return sql.begin(async (tx) => {
516
+ const rows = await tx<TaskRow[]>`
517
+ ${taskSelect}
518
+ WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
519
+ FOR UPDATE OF task
520
+ `;
521
+ let current = rows[0];
522
+ if (!current) return null;
523
+ requireTaskMandate(current);
524
+ const allowed =
525
+ current.state === input.state ||
526
+ (current.state === "active" && input.state === "paused") ||
527
+ (current.state === "paused" && input.state === "active") ||
528
+ (current.state === "needs_attention" && current.schedule_kind === "cron" && input.state === "active");
529
+ if (!allowed) return null;
530
+ const mandate = await loadTaskMandate(current, tx);
531
+ if (!mandate || !taskMandateMatches(current, mandate)) {
532
+ throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
533
+ }
534
+ if (input.state === "active") {
535
+ const policy = taskMandatePolicy(mandate);
536
+ if (!mandate.confirmed_at || !mandate.unexpired || !policy || !isChatTaskMandatePolicy(policy)) {
537
+ throw new AiChatTaskAuthorityError("Scheduled task mandate cannot be resumed");
538
+ }
539
+ }
540
+ const revision = await mandateRevision({ mandate_id: mandate.id, mandate_revision: mandate.revision }, input.state, input.userId, tx);
541
+ const updated = await tx<TaskRow[]>`
542
+ UPDATE ai.chat_tasks task
543
+ SET state = ${input.state}, mandate_revision = ${revision},
544
+ last_error = CASE WHEN task.state = ${input.state} THEN task.last_error ELSE NULL END,
545
+ revision = CASE WHEN task.state = ${input.state} THEN task.revision ELSE task.revision + 1 END,
546
+ updated_at = CASE WHEN task.state = ${input.state} THEN task.updated_at ELSE now() END
547
+ FROM ai.conversations conversation
548
+ WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
549
+ RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
550
+ `;
551
+ return updated[0] ? toTask(updated[0]) : null;
552
+ });
553
+ },
554
+
555
+ delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
556
+ return sql.begin(async (tx) => {
557
+ const rows = await tx<TaskRow[]>`
558
+ ${taskSelect}
559
+ WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
560
+ FOR UPDATE OF task
561
+ `;
562
+ let current = rows[0];
563
+ if (!current) return false;
564
+ if (!current.mandate_id && current.mandate_revision === null) {
565
+ await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
566
+ return true;
567
+ }
568
+ requireTaskMandate(current);
569
+ const mandate = await loadTaskMandate(current, tx);
570
+ if (!mandate || !taskMandateMatches(current, mandate)) {
571
+ throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
572
+ }
573
+ const revoked = await revokeMandate(
574
+ {
575
+ mandateId: mandate.id,
576
+ expectedRevision: Number(mandate.revision),
577
+ authority: mandateAuthority(input.userId),
578
+ reason: "Scheduled chat task deleted",
579
+ },
580
+ { db: tx },
581
+ );
582
+ if (!revoked.ok) throw mandateError("revoke", revoked);
583
+ await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
584
+ return true;
585
+ });
586
+ },
587
+
588
+ listOccurrences: async (input: { userId: string; taskId: string; limit?: number }): Promise<AiChatTaskOccurrence[] | null> => {
589
+ const task = await aiChatTasks.get(input);
590
+ if (!task) return null;
591
+ const rows = await sql<OccurrenceRow[]>`
592
+ SELECT * FROM ai.chat_task_occurrences
593
+ WHERE task_id = ${task.id}::uuid
594
+ ORDER BY created_at DESC, id DESC
595
+ LIMIT ${Math.min(Math.max(input.limit ?? 20, 1), 100)}
596
+ `;
597
+ return rows.map(toOccurrence);
598
+ },
599
+
600
+ listActiveCron: async (): Promise<AiChatTask[]> => {
601
+ const rows = await sql<TaskRow[]>`
602
+ ${taskSelect}
603
+ WHERE task.state = 'active' AND task.schedule_kind = 'cron'
604
+ `;
605
+ const tasks: AiChatTask[] = [];
606
+ for (const row of rows) {
607
+ try {
608
+ const prepared = await prepareTaskById(row.id);
609
+ if (prepared) tasks.push(prepared);
610
+ } catch (error) {
611
+ log.warn("Scheduled task reconciliation failed", { taskId: row.id, error: error instanceof Error ? error.message : String(error) });
612
+ // Retain the registration during transient storage failures. Every run still revalidates authority.
613
+ tasks.push(toTask(row));
614
+ }
615
+ }
616
+ return tasks;
617
+ },
618
+
619
+ createOccurrence: async (input: {
620
+ taskId: string;
621
+ scheduledFor: string;
622
+ trigger: "scheduled" | "manual";
623
+ requestKey: string;
624
+ expectedRevision?: number;
625
+ }): Promise<AiChatTaskOccurrence | null> => {
626
+ return sql.begin(async (tx) => {
627
+ const [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
628
+ if (!current || !(await prepareTaskForExecution(current, tx))) return null;
629
+ const rows = await withAiShortIdForDb(
630
+ tx,
631
+ "ai_chat_task_occurrences_short_id_unique",
632
+ (attempt, shortId) => attempt<OccurrenceRow[]>`
633
+ INSERT INTO ai.chat_task_occurrences (short_id, task_id, scheduled_for, trigger, request_key, task_revision)
634
+ SELECT ${shortId}, task.id, ${input.scheduledFor}::timestamptz, ${input.trigger}, ${input.requestKey}, task.revision
635
+ FROM ai.chat_tasks task
636
+ WHERE task.id = ${input.taskId}::uuid AND task.state = 'active'
637
+ AND (${input.expectedRevision ?? null}::bigint IS NULL OR task.revision = ${input.expectedRevision ?? null}::bigint)
638
+ ON CONFLICT DO NOTHING
639
+ RETURNING *
640
+ `,
641
+ );
642
+ if (rows[0]) return toOccurrence(rows[0]);
643
+ const existing = await tx<OccurrenceRow[]>`
644
+ SELECT * FROM ai.chat_task_occurrences WHERE request_key = ${input.requestKey} LIMIT 1
645
+ `;
646
+ if (!existing[0]) return null;
647
+ if (existing[0].task_id !== input.taskId) throw new AiChatTaskIdempotencyConflictError();
648
+ return toOccurrence(existing[0]);
649
+ });
650
+ },
651
+
652
+ materializeDueOnce: async (limit = 100): Promise<AiChatTaskOccurrence[]> => {
653
+ const tasks = await sql<{ id: string; run_at: Date | string; revision: number | bigint }[]>`
654
+ SELECT task.id, task.run_at, task.revision
655
+ FROM ai.chat_tasks task
656
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
657
+ WHERE task.state = 'active' AND task.schedule_kind = 'once' AND task.run_at <= now()
658
+ ORDER BY run_at, id
659
+ LIMIT ${Math.min(Math.max(limit, 1), 500)}
660
+ `;
661
+ const occurrences: AiChatTaskOccurrence[] = [];
662
+ for (const task of tasks) {
663
+ try {
664
+ const slot = iso(task.run_at);
665
+ const occurrence = await aiChatTasks.createOccurrence({
666
+ taskId: task.id,
667
+ scheduledFor: slot,
668
+ trigger: "scheduled",
669
+ requestKey: `once:${task.id}:${slot}`,
670
+ expectedRevision: Number(task.revision),
671
+ });
672
+ if (occurrence?.state === "queued") occurrences.push(occurrence);
673
+ } catch (error) {
674
+ log.warn("Scheduled task materialization failed", {
675
+ taskId: task.id,
676
+ error: error instanceof Error ? error.message : String(error),
677
+ });
678
+ }
679
+ }
680
+ return occurrences;
681
+ },
682
+
683
+ listQueuedOccurrences: async (limit = 100): Promise<Array<{ occurrence: AiChatTaskOccurrence; task: AiChatTask }>> => {
684
+ const rows = await sql<(OccurrenceRow & TaskRow)[]>`
685
+ SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
686
+ occurrence.state, occurrence.turn_id, occurrence.error, occurrence.created_at, occurrence.started_at, occurrence.completed_at,
687
+ task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
688
+ conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
689
+ task.prompt, task.schedule_kind, task.run_at, task.cron,
690
+ task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
691
+ FROM (
692
+ SELECT candidate.*,
693
+ row_number() OVER (PARTITION BY task.conversation_id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
694
+ FROM ai.chat_task_occurrences candidate
695
+ JOIN ai.chat_tasks task ON task.id = candidate.task_id
696
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
697
+ WHERE candidate.state = 'queued' AND task.state = 'active'
698
+ AND task.mandate_id IS NOT NULL AND task.mandate_revision IS NOT NULL
699
+ AND NOT EXISTS (
700
+ SELECT 1 FROM ai.turns turn
701
+ WHERE turn.conversation_id = task.conversation_id
702
+ AND turn.status IN ('queued', 'running', 'waiting_for_action')
703
+ )
704
+ ) occurrence
705
+ JOIN ai.chat_tasks task ON task.id = occurrence.task_id
706
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
707
+ WHERE occurrence.conversation_rank = 1
708
+ ORDER BY occurrence.created_at, occurrence.id
709
+ LIMIT ${Math.min(Math.max(limit, 1), 500)}
710
+ `;
711
+ return rows.map((row) => ({
712
+ occurrence: toOccurrence(row),
713
+ task: toTask({
714
+ ...row,
715
+ id: (row as typeof row & { task_row_id: string }).task_row_id,
716
+ short_id: (row as typeof row & { task_short_id: string }).task_short_id,
717
+ state: (row as typeof row & { task_state: AiChatTaskState }).task_state,
718
+ created_at: (row as typeof row & { task_created_at: Date | string }).task_created_at,
719
+ }),
720
+ }));
721
+ },
722
+
723
+ getQueuedOccurrence: async (occurrenceId: string): Promise<{ occurrence: AiChatTaskOccurrence; task: AiChatTask } | null> => {
724
+ return sql.begin(async (tx) => {
725
+ const occurrences = await tx<OccurrenceRow[]>`
726
+ SELECT * FROM ai.chat_task_occurrences WHERE id = ${occurrenceId}::uuid AND state = 'queued' LIMIT 1
727
+ `;
728
+ const occurrence = occurrences[0];
729
+ if (!occurrence) return null;
730
+ const tasks = await tx<TaskRow[]>`
731
+ ${taskSelect}
732
+ WHERE task.id = ${occurrence.task_id}::uuid AND task.state = 'active'
733
+ LIMIT 1
734
+ FOR UPDATE OF task
735
+ `;
736
+ if (!tasks[0]) return null;
737
+ const task = await prepareTaskForExecution(tasks[0], tx);
738
+ if (!task) return null;
739
+ await tx`
740
+ UPDATE ai.chat_task_occurrences SET task_revision = ${task.revision}::bigint
741
+ WHERE id = ${occurrence.id}::uuid AND state = 'queued'
742
+ `;
743
+ return { occurrence: toOccurrence(occurrence), task: toTask(task) };
744
+ });
745
+ },
746
+
747
+ failOccurrence: async (input: { occurrenceId: string; error: string }): Promise<"failed" | "stale" | "gone"> =>
748
+ sql.begin(async (tx) => {
749
+ const task = await loadOccurrenceTask(input.occurrenceId, tx);
750
+ if (!task) return "gone" as const;
751
+ const rows = await tx<{ task_id: string; task_revision: number | bigint }[]>`
752
+ UPDATE ai.chat_task_occurrences occurrence
753
+ SET state = 'failed', error = ${input.error}, completed_at = now()
754
+ FROM ai.chat_tasks task
755
+ WHERE occurrence.task_id = task.id
756
+ AND occurrence.id = ${input.occurrenceId}::uuid
757
+ AND occurrence.state IN ('queued', 'running')
758
+ AND occurrence.task_revision = task.revision
759
+ RETURNING occurrence.task_id, occurrence.task_revision
760
+ `;
761
+ if (rows[0]) {
762
+ const revision = await pauseTerminalTaskMandate(task, tx);
763
+ await tx`
764
+ UPDATE ai.chat_tasks
765
+ SET state = 'needs_attention', last_error = ${input.error}, mandate_revision = ${revision},
766
+ revision = revision + 1, updated_at = now()
767
+ WHERE id = ${rows[0].task_id}::uuid AND revision = ${rows[0].task_revision}::bigint
768
+ `;
769
+ return "failed" as const;
770
+ }
771
+ const stale = await tx<{ stale: boolean }[]>`
772
+ SELECT true AS stale
773
+ FROM ai.chat_task_occurrences occurrence
774
+ JOIN ai.chat_tasks task ON task.id = occurrence.task_id
775
+ WHERE occurrence.id = ${input.occurrenceId}::uuid
776
+ AND occurrence.state IN ('queued', 'running')
777
+ AND occurrence.task_revision <> task.revision
778
+ LIMIT 1
779
+ `;
780
+ return stale[0] ? ("stale" as const) : ("gone" as const);
781
+ }),
782
+
783
+ deliverOccurrence: async (input: {
784
+ occurrenceId: string;
785
+ modelProfileId: string;
786
+ runConfig: AiChatTurnRunConfig;
787
+ userMessage: Message;
788
+ expectedRevision: number;
789
+ }): Promise<{ delivered: true; conversationId: string; turnId: string } | { delivered: false; reason: "not_found" | "busy" | "stale" }> =>
790
+ sql.begin(async (tx) => {
791
+ const current = await loadOccurrenceTask(input.occurrenceId, tx);
792
+ const task = current ? await prepareTaskForExecution(current, tx) : null;
793
+ if (!task) {
794
+ return { delivered: false as const, reason: "not_found" as const };
795
+ }
796
+ const rows = await tx<
797
+ {
798
+ id: string;
799
+ short_id: string;
800
+ task_id: string;
801
+ task_short_id: string;
802
+ scheduled_for: Date | string;
803
+ trigger: "scheduled" | "manual";
804
+ state: AiChatTaskOccurrenceState;
805
+ conversation_id: string;
806
+ archived_at: Date | null;
807
+ task_revision: number | bigint;
808
+ }[]
809
+ >`
810
+ SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
811
+ occurrence.state, task.short_id AS task_short_id, task.conversation_id, conversation.archived_at,
812
+ task.revision AS task_revision
813
+ FROM ai.chat_task_occurrences occurrence
814
+ JOIN ai.chat_tasks task ON task.id = occurrence.task_id
815
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
816
+ WHERE occurrence.id = ${input.occurrenceId}::uuid AND task.state = 'active'
817
+ FOR UPDATE OF occurrence, task, conversation
818
+ `;
819
+ const occurrence = rows[0];
820
+ if (!occurrence || occurrence.state !== "queued") return { delivered: false as const, reason: "not_found" as const };
821
+ if (occurrence.archived_at) return { delivered: false as const, reason: "not_found" as const };
822
+ if (Number(occurrence.task_revision) !== input.expectedRevision) return { delivered: false as const, reason: "stale" as const };
823
+ const active = await tx<{ id: string }[]>`
824
+ SELECT id FROM ai.turns
825
+ WHERE conversation_id = ${occurrence.conversation_id}::uuid
826
+ AND status IN ('queued', 'running', 'waiting_for_action')
827
+ LIMIT 1
828
+ `;
829
+ if (active[0]) return { delivered: false as const, reason: "busy" as const };
830
+ const mandate = requireTaskMandate(task);
831
+ const runConfig: AiChatTurnRunConfig = {
832
+ ...input.runConfig,
833
+ mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
834
+ };
835
+ const turn = await withAiShortIdForDb(
836
+ tx,
837
+ "idx_ai_turns_conversation_short_id",
838
+ (attempt, shortId) => attempt<{ id: string }[]>`
839
+ INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
840
+ VALUES (${shortId}, ${occurrence.conversation_id}::uuid, ${input.modelProfileId}, 'queued', (${JSON.stringify(runConfig)}::text)::jsonb)
841
+ RETURNING id
842
+ `,
843
+ );
844
+ const turnId = turn[0]!.id;
845
+ const messageMeta: NonNullable<AiStoredMessage["meta"]> = {
846
+ scheduledTask: {
847
+ taskId: occurrence.task_short_id,
848
+ occurrenceId: occurrence.short_id,
849
+ scheduledFor: iso(occurrence.scheduled_for),
850
+ trigger: occurrence.trigger,
851
+ },
852
+ };
853
+ await withAiShortIdForDb(
854
+ tx,
855
+ "idx_ai_messages_conversation_short_id",
856
+ (attempt, shortId) => attempt`
857
+ INSERT INTO ai.messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
858
+ VALUES (
859
+ ${shortId}, ${occurrence.conversation_id}::uuid,
860
+ (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${occurrence.conversation_id}::uuid AND seq > 0),
861
+ 'message', 'user', (${JSON.stringify(input.userMessage)}::text)::jsonb, ${input.runConfig.input}, ${turnId}::uuid,
862
+ (${JSON.stringify(messageMeta)}::text)::jsonb
863
+ )
864
+ `,
865
+ );
866
+ await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${occurrence.conversation_id}::uuid`;
867
+ await tx`
868
+ UPDATE ai.chat_task_occurrences
869
+ SET state = 'running', turn_id = ${turnId}::uuid, task_revision = ${occurrence.task_revision}::bigint, started_at = now()
870
+ WHERE id = ${occurrence.id}::uuid
871
+ `;
872
+ return { delivered: true as const, conversationId: occurrence.conversation_id, turnId };
873
+ }),
874
+
875
+ finalizeTurn: async (input: {
876
+ turnId: string;
877
+ status: AiTurnStatus;
878
+ error?: string | null;
879
+ }): Promise<{ occurrenceId: string; failed: boolean } | null> =>
880
+ sql.begin(async (tx) => {
881
+ const task = await loadTurnTask(input.turnId, tx);
882
+ if (!task) return null;
883
+ const rows = await tx<
884
+ {
885
+ occurrence_id: string;
886
+ task_id: string;
887
+ schedule_kind: "once" | "cron";
888
+ trigger: "scheduled" | "manual";
889
+ scheduled_for: Date | string;
890
+ run_at: Date | string | null;
891
+ task_revision: number | bigint;
892
+ current_revision: number | bigint;
893
+ turn_error: string | null;
894
+ }[]
895
+ >`
896
+ UPDATE ai.chat_task_occurrences occurrence
897
+ SET state = ${input.status === "completed" ? "completed" : "failed"},
898
+ error = CASE
899
+ WHEN ${input.status} = 'completed' THEN NULL
900
+ ELSE COALESCE(${input.error ?? null}, turn.error, ${`Scheduled turn ${input.status}`})
901
+ END,
902
+ completed_at = now()
903
+ FROM ai.chat_tasks task, ai.turns turn
904
+ WHERE occurrence.task_id = task.id AND occurrence.turn_id = ${input.turnId}::uuid AND turn.id = occurrence.turn_id
905
+ AND occurrence.state = 'running'
906
+ RETURNING occurrence.id AS occurrence_id, occurrence.task_id, task.schedule_kind, occurrence.trigger,
907
+ occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error
908
+ `;
909
+ const row = rows[0];
910
+ if (!row) return null;
911
+ const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
912
+ const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
913
+ if (
914
+ unchangedSinceStart &&
915
+ input.status === "completed" &&
916
+ row.schedule_kind === "once" &&
917
+ row.trigger === "scheduled" &&
918
+ currentOnceSlot
919
+ ) {
920
+ const revision = await pauseTerminalTaskMandate(task, tx);
921
+ await tx`
922
+ UPDATE ai.chat_tasks
923
+ SET state = 'completed', last_error = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
924
+ WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
925
+ `;
926
+ } else if (unchangedSinceStart && input.status !== "completed") {
927
+ const error = input.error || row.turn_error || `Scheduled turn ${input.status}`;
928
+ const revision = await pauseTerminalTaskMandate(task, tx);
929
+ await tx`
930
+ UPDATE ai.chat_tasks
931
+ SET state = 'needs_attention', last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
932
+ WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
933
+ `;
934
+ }
935
+ return { occurrenceId: row.occurrence_id, failed: input.status !== "completed" };
936
+ }),
937
+
938
+ listTerminalRunningTurns: async (limit = 100): Promise<Array<{ turnId: string; status: "completed" | "failed" | "aborted" }>> => {
939
+ const rows = await sql<{ turn_id: string; status: "completed" | "failed" | "aborted" }[]>`
940
+ SELECT occurrence.turn_id, turn.status
941
+ FROM ai.chat_task_occurrences occurrence
942
+ JOIN ai.chat_tasks task ON task.id = occurrence.task_id
943
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
944
+ JOIN ai.turns turn ON turn.id = occurrence.turn_id
945
+ WHERE occurrence.state = 'running'
946
+ AND turn.status IN ('completed', 'failed', 'aborted')
947
+ ORDER BY turn.completed_at, occurrence.id
948
+ LIMIT ${Math.min(Math.max(limit, 1), 500)}
949
+ `;
950
+ return rows.map((row) => ({ turnId: row.turn_id, status: row.status }));
951
+ },
952
+ } as const;