@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,2082 @@
1
+ import { sql } from "bun";
2
+ import { migrateAiModelAccess } from "./model-access-migrate";
3
+ import { withAiShortId } from "./short-id";
4
+ import { migrateAiTurnUsage } from "./usage-migrate";
5
+
6
+ const backfillAiShortIds = async (
7
+ constraint: string,
8
+ rows: { id: string }[],
9
+ update: (id: string, shortId: string) => Promise<unknown>,
10
+ ): Promise<void> => {
11
+ for (const row of rows) await withAiShortId(constraint, (shortId) => update(row.id, shortId));
12
+ };
13
+
14
+ export const migrateCloudAi = async (): Promise<void> => {
15
+ await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
16
+ console.log(" ✓ ai schema");
17
+
18
+ await sql`
19
+ CREATE TABLE IF NOT EXISTS ai.conversations (
20
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
21
+ short_id TEXT NOT NULL,
22
+ title TEXT NOT NULL DEFAULT 'New chat',
23
+ description TEXT NOT NULL DEFAULT '',
24
+ loaded_tools TEXT[] NOT NULL DEFAULT '{}',
25
+ draft_content JSONB NOT NULL DEFAULT '[]'::jsonb,
26
+ draft_revision BIGINT NOT NULL DEFAULT 0,
27
+ draft_updated_at TIMESTAMPTZ,
28
+ created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
29
+ launched_by_app_id TEXT,
30
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
31
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
32
+ archived_at TIMESTAMPTZ
33
+ )
34
+ `.simple();
35
+
36
+ // AI chat ownership was unreleased and only backed local test data. Make the
37
+ // product-model cut once, then keep only the global conversation contract.
38
+ await sql`
39
+ DO $$
40
+ BEGIN
41
+ IF EXISTS (
42
+ SELECT 1 FROM information_schema.columns
43
+ WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'app_id'
44
+ ) THEN
45
+ DELETE FROM ai.conversations;
46
+ ALTER TABLE ai.conversations DROP CONSTRAINT IF EXISTS ai_conversations_project_app_fkey;
47
+ DROP INDEX IF EXISTS ai.idx_ai_conversations_app_owner_updated;
48
+ DROP INDEX IF EXISTS ai.idx_ai_conversations_app_owner_pinned;
49
+ ALTER TABLE ai.conversations
50
+ DROP COLUMN app_id,
51
+ DROP COLUMN resource_kind,
52
+ DROP COLUMN resource_app_id,
53
+ DROP COLUMN resource_type,
54
+ DROP COLUMN resource_id;
55
+ END IF;
56
+ END $$
57
+ `.simple();
58
+
59
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
60
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_conversations_short_id ON ai.conversations(short_id)`.simple();
61
+ await backfillAiShortIds(
62
+ "idx_ai_conversations_short_id",
63
+ await sql<{ id: string }[]>`SELECT id FROM ai.conversations WHERE short_id IS NULL`,
64
+ (id, shortId) => sql`UPDATE ai.conversations SET short_id = ${shortId} WHERE id = ${id}::uuid`,
65
+ );
66
+ await sql`ALTER TABLE ai.conversations ALTER COLUMN short_id SET NOT NULL`.simple();
67
+
68
+ await sql`ALTER TABLE ai.conversations DROP COLUMN IF EXISTS icon`.simple();
69
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`.simple();
70
+ await sql`
71
+ DO $$
72
+ BEGIN
73
+ IF EXISTS (
74
+ SELECT 1 FROM information_schema.columns
75
+ WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'loaded_capabilities'
76
+ ) AND NOT EXISTS (
77
+ SELECT 1 FROM information_schema.columns
78
+ WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'loaded_tools'
79
+ ) THEN
80
+ ALTER TABLE ai.conversations RENAME COLUMN loaded_capabilities TO loaded_tools;
81
+ ELSIF EXISTS (
82
+ SELECT 1 FROM information_schema.columns
83
+ WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'loaded_capabilities'
84
+ ) THEN
85
+ UPDATE ai.conversations
86
+ SET loaded_tools = ARRAY(
87
+ SELECT DISTINCT name
88
+ FROM unnest(loaded_tools || loaded_capabilities) AS name
89
+ );
90
+ ALTER TABLE ai.conversations DROP COLUMN loaded_capabilities;
91
+ END IF;
92
+ END $$
93
+ `.simple();
94
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS loaded_tools TEXT[] NOT NULL DEFAULT '{}'`.simple();
95
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS allowed_tools TEXT[]`.simple();
96
+ // Capability tools used to persist provider-encoded function names. The
97
+ // encoding is reversible for every non-hashed valid capability id; hashed
98
+ // names were only cache state and are safely reloaded through discovery.
99
+ await sql`
100
+ UPDATE ai.conversations
101
+ SET loaded_tools = ARRAY(
102
+ SELECT converted_name
103
+ FROM (
104
+ SELECT
105
+ CASE
106
+ WHEN name ~ '^[a-z][a-z0-9-]*__(query|action)__'
107
+ THEN substring(name FROM '^([a-z][a-z0-9-]*)__') || '.' ||
108
+ replace(
109
+ replace(regexp_replace(name, '^[a-z][a-z0-9-]*__(query|action)__', ''), '_dot_', '.'),
110
+ '__',
111
+ '_'
112
+ )
113
+ ELSE name
114
+ END AS converted_name,
115
+ position
116
+ FROM unnest(loaded_tools) WITH ORDINALITY AS loaded(name, position)
117
+ WHERE NOT (
118
+ length(name) = 64
119
+ AND name ~ '^[a-z][a-z0-9-]*__(query|action)__'
120
+ AND name ~ '__[0-9a-f]{12}$'
121
+ )
122
+ ) AS converted
123
+ GROUP BY converted_name
124
+ ORDER BY min(position)
125
+ )
126
+ WHERE EXISTS (
127
+ SELECT 1 FROM unnest(loaded_tools) AS name
128
+ WHERE name ~ '^[a-z][a-z0-9-]*__(query|action)__'
129
+ )
130
+ `.simple();
131
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS draft_content JSONB NOT NULL DEFAULT '[]'::jsonb`.simple();
132
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS draft_revision BIGINT NOT NULL DEFAULT 0`.simple();
133
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS draft_updated_at TIMESTAMPTZ`.simple();
134
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS launched_by_app_id TEXT`.simple();
135
+ // Enrichment (description/keywords/title upkeep) — dirty = updated_at > enriched_at.
136
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS keywords TEXT[] NOT NULL DEFAULT '{}'`.simple();
137
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_summary TEXT NOT NULL DEFAULT ''`.simple();
138
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS title_source TEXT NOT NULL DEFAULT 'default'`.simple();
139
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS description_source TEXT NOT NULL DEFAULT 'default'`.simple();
140
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ`.simple();
141
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_failed_at TIMESTAMPTZ`.simple();
142
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_fail_count INTEGER NOT NULL DEFAULT 0`.simple();
143
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ`.simple();
144
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS last_viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()`.simple();
145
+ await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET DEFAULT now()`.simple();
146
+ await sql`UPDATE ai.conversations SET last_viewed_at = now() WHERE last_viewed_at IS NULL`.simple();
147
+ await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET NOT NULL`.simple();
148
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT ''`.simple();
149
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_document TSVECTOR NOT NULL DEFAULT ''::tsvector`.simple();
150
+ await sql`
151
+ CREATE OR REPLACE FUNCTION ai.refresh_conversation_search() RETURNS trigger AS $$
152
+ BEGIN
153
+ NEW.search_text := COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.title, '') || ' ' ||
154
+ COALESCE(array_to_string(NEW.keywords, ' '), '') || ' ' || COALESCE(NEW.search_summary, '') || ' ' ||
155
+ COALESCE(NEW.description, '');
156
+ NEW.search_document :=
157
+ setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
158
+ setweight(to_tsvector('simple', COALESCE(array_to_string(NEW.keywords, ' '), '')), 'A') ||
159
+ setweight(to_tsvector('simple', COALESCE(NEW.search_summary, '')), 'B') ||
160
+ setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'C');
161
+ RETURN NEW;
162
+ END
163
+ $$ LANGUAGE plpgsql
164
+ `.simple();
165
+ await sql`DROP TRIGGER IF EXISTS ai_conversations_search_refresh ON ai.conversations`.simple();
166
+ await sql`
167
+ CREATE TRIGGER ai_conversations_search_refresh
168
+ BEFORE INSERT OR UPDATE OF title, description, keywords, search_summary
169
+ ON ai.conversations FOR EACH ROW EXECUTE FUNCTION ai.refresh_conversation_search()
170
+ `.simple();
171
+ await sql`UPDATE ai.conversations SET search_summary = search_summary`.simple();
172
+ await sql`
173
+ CREATE INDEX IF NOT EXISTS idx_ai_conversations_search_document
174
+ ON ai.conversations USING GIN(search_document)
175
+ `.simple();
176
+
177
+ // Semantics fix: 'auto' is reserved for enrichment-set titles (which always set
178
+ // enriched_at in the same update). First-message snapshot titles are 'default'
179
+ // so enrichment may replace them freely. No-op once the code writes it that way.
180
+ await sql`UPDATE ai.conversations SET title_source = 'default' WHERE title_source = 'auto' AND enriched_at IS NULL`.simple();
181
+
182
+ // The AI summary moved into the user-visible description (guarded by
183
+ // description_source, same pattern as title_source). Migrate stored
184
+ // summaries into empty descriptions, then drop the column.
185
+ await sql`
186
+ DO $$
187
+ BEGIN
188
+ IF EXISTS (
189
+ SELECT 1 FROM information_schema.columns
190
+ WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'summary'
191
+ ) THEN
192
+ UPDATE ai.conversations
193
+ SET description = LEFT(summary, 500), description_source = 'auto'
194
+ WHERE description = '' AND summary <> '';
195
+ ALTER TABLE ai.conversations DROP COLUMN summary;
196
+ END IF;
197
+ END $$
198
+ `.simple();
199
+
200
+ await sql`
201
+ CREATE INDEX IF NOT EXISTS idx_ai_conversations_enrich_dirty
202
+ ON ai.conversations(updated_at ASC)
203
+ WHERE archived_at IS NULL
204
+ `.simple();
205
+
206
+ // Per-conversation enrichment history — user-visible in the chat settings.
207
+ await sql`
208
+ CREATE TABLE IF NOT EXISTS ai.enrichment_runs (
209
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
210
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
211
+ status TEXT NOT NULL,
212
+ trigger TEXT NOT NULL DEFAULT 'scheduled',
213
+ model_profile_id TEXT,
214
+ mode TEXT,
215
+ duration_ms INTEGER,
216
+ title_updated BOOLEAN NOT NULL DEFAULT FALSE,
217
+ keywords_count INTEGER NOT NULL DEFAULT 0,
218
+ error TEXT,
219
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
220
+ CONSTRAINT ai_enrichment_runs_status_check CHECK (status IN ('ok', 'failed', 'skipped')),
221
+ CONSTRAINT ai_enrichment_runs_trigger_check CHECK (trigger IN ('scheduled', 'manual'))
222
+ )
223
+ `.simple();
224
+
225
+ await sql`
226
+ CREATE INDEX IF NOT EXISTS idx_ai_enrichment_runs_conversation_created
227
+ ON ai.enrichment_runs(conversation_id, created_at DESC)
228
+ `.simple();
229
+
230
+ // Durable, metadata-only accounting for every runAiStructured call. Traces
231
+ // remain the operational drill-down; this ledger survives trace retention
232
+ // without storing prompts, inputs, or model output.
233
+ await sql`
234
+ CREATE TABLE IF NOT EXISTS ai.structured_runs (
235
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
236
+ task TEXT NOT NULL CHECK (char_length(task) BETWEEN 1 AND 120),
237
+ app_id TEXT,
238
+ model_profile_id TEXT,
239
+ provider_model TEXT,
240
+ status TEXT NOT NULL CHECK (status IN ('ok', 'failed')),
241
+ duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
242
+ input_tokens INTEGER CHECK (input_tokens >= 0),
243
+ output_tokens INTEGER CHECK (output_tokens >= 0),
244
+ total_tokens INTEGER CHECK (total_tokens >= 0),
245
+ credits_used DOUBLE PRECISION CHECK (credits_used >= 0),
246
+ mode TEXT,
247
+ repaired BOOLEAN,
248
+ attempts INTEGER CHECK (attempts IS NULL OR attempts >= 1),
249
+ error_code TEXT,
250
+ error TEXT CHECK (error IS NULL OR char_length(error) <= 2000),
251
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
252
+ )
253
+ `.simple();
254
+ await sql`
255
+ CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_created
256
+ ON ai.structured_runs(created_at DESC)
257
+ `.simple();
258
+ await sql`
259
+ CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_model_created
260
+ ON ai.structured_runs(model_profile_id, created_at DESC)
261
+ `.simple();
262
+
263
+ await sql`
264
+ CREATE INDEX IF NOT EXISTS idx_ai_conversations_owner_updated
265
+ ON ai.conversations(created_by_user_id, updated_at DESC)
266
+ WHERE archived_at IS NULL
267
+ `.simple();
268
+
269
+ await sql`
270
+ CREATE INDEX IF NOT EXISTS idx_ai_conversations_owner_pinned
271
+ ON ai.conversations(created_by_user_id, pinned_at DESC, updated_at DESC)
272
+ WHERE archived_at IS NULL
273
+ `.simple();
274
+
275
+ await sql`
276
+ CREATE TABLE IF NOT EXISTS ai.messages (
277
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
278
+ short_id TEXT NOT NULL,
279
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
280
+ seq INTEGER NOT NULL,
281
+ kind TEXT NOT NULL DEFAULT 'message',
282
+ role TEXT NOT NULL,
283
+ message JSONB NOT NULL,
284
+ model_profile_id TEXT,
285
+ provider_model TEXT,
286
+ usage JSONB,
287
+ stop_reason TEXT,
288
+ loop_id TEXT,
289
+ loop_aggregate JSONB,
290
+ loop_done_reason TEXT,
291
+ compacted_at TIMESTAMPTZ,
292
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
293
+ CONSTRAINT ai_messages_kind_check CHECK (kind IN ('message', 'summary')),
294
+ CONSTRAINT ai_messages_role_check CHECK (role IN ('user', 'assistant', 'tool_result'))
295
+ )
296
+ `.simple();
297
+
298
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
299
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_messages_conversation_short_id ON ai.messages(conversation_id, short_id)`.simple();
300
+ await backfillAiShortIds(
301
+ "idx_ai_messages_conversation_short_id",
302
+ await sql<{ id: string }[]>`SELECT id FROM ai.messages WHERE short_id IS NULL`,
303
+ (id, shortId) => sql`UPDATE ai.messages SET short_id = ${shortId} WHERE id = ${id}::uuid`,
304
+ );
305
+ await sql`ALTER TABLE ai.messages ALTER COLUMN short_id SET NOT NULL`.simple();
306
+
307
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS compacted_at TIMESTAMPTZ`.simple();
308
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS loop_id TEXT`.simple();
309
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS loop_aggregate JSONB`.simple();
310
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS loop_done_reason TEXT`.simple();
311
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS meta JSONB`.simple();
312
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_rating SMALLINT`.simple();
313
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_reasons TEXT[] NOT NULL DEFAULT '{}'`.simple();
314
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_comment TEXT`.simple();
315
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_updated_at TIMESTAMPTZ`.simple();
316
+ await sql`
317
+ ALTER TABLE ai.messages DROP CONSTRAINT IF EXISTS ai_messages_feedback_check;
318
+ ALTER TABLE ai.messages ADD CONSTRAINT ai_messages_feedback_check CHECK (
319
+ (feedback_rating IS NULL AND cardinality(feedback_reasons) = 0 AND feedback_comment IS NULL AND feedback_updated_at IS NULL)
320
+ OR (feedback_rating = 1 AND cardinality(feedback_reasons) = 0 AND feedback_comment IS NULL AND feedback_updated_at IS NOT NULL)
321
+ OR (
322
+ feedback_rating = -1
323
+ AND feedback_updated_at IS NOT NULL
324
+ AND (cardinality(feedback_reasons) > 0 OR feedback_comment IS NOT NULL)
325
+ AND feedback_reasons <@ ARRAY[
326
+ 'incorrect', 'did_not_follow_request', 'incomplete', 'poor_tool_choice', 'too_slow', 'other'
327
+ ]::text[]
328
+ )
329
+ )
330
+ `.simple();
331
+ await sql`
332
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_assistant_created
333
+ ON ai.messages(created_at DESC)
334
+ WHERE role = 'assistant' AND kind = 'message'
335
+ `.simple();
336
+ await sql`
337
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_feedback_updated
338
+ ON ai.messages(feedback_updated_at DESC)
339
+ WHERE feedback_rating IS NOT NULL
340
+ `.simple();
341
+ await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT ''`.simple();
342
+ await sql`
343
+ ALTER TABLE ai.messages
344
+ ADD COLUMN IF NOT EXISTS search_document tsvector
345
+ GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(search_text, ''))) STORED
346
+ `.simple();
347
+ await sql`
348
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_search_document
349
+ ON ai.messages USING GIN(search_document)
350
+ `.simple();
351
+
352
+ // Seq is only unique among active (non-compacted) rows: compaction archives rows
353
+ // in place and the summary takes over the checkpoint seq.
354
+ await sql`ALTER TABLE ai.messages DROP CONSTRAINT IF EXISTS ai_messages_seq_unique`.simple();
355
+ await sql`
356
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_messages_active_seq_unique
357
+ ON ai.messages(conversation_id, seq)
358
+ WHERE compacted_at IS NULL
359
+ `.simple();
360
+
361
+ await sql`
362
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation_seq
363
+ ON ai.messages(conversation_id, seq ASC)
364
+ `.simple();
365
+
366
+ await sql`
367
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_active_conversation_seq
368
+ ON ai.messages(conversation_id, seq ASC)
369
+ WHERE compacted_at IS NULL
370
+ `.simple();
371
+
372
+ await sql`
373
+ CREATE INDEX IF NOT EXISTS idx_ai_messages_active_conversation_loop_seq
374
+ ON ai.messages(conversation_id, loop_id, seq ASC)
375
+ WHERE compacted_at IS NULL AND loop_id IS NOT NULL
376
+ `.simple();
377
+
378
+ // Optional pg_textsearch ranking. Native weighted FTS above is always the
379
+ // correctness path; missing extension support must never block startup.
380
+ try {
381
+ const [extension] = await sql<{ available: boolean; installed: boolean; server_version: number }[]>`
382
+ SELECT
383
+ EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_textsearch') AS available,
384
+ EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed,
385
+ current_setting('server_version_num')::int AS server_version
386
+ `;
387
+ let installed = extension?.installed ?? false;
388
+ if (!installed && extension?.available && extension.server_version >= 170000) {
389
+ await sql`CREATE EXTENSION IF NOT EXISTS pg_textsearch`.simple();
390
+ installed = true;
391
+ }
392
+ if (installed) {
393
+ await sql
394
+ .unsafe(`
395
+ CREATE INDEX IF NOT EXISTS conversations_search_bm25_idx
396
+ ON ai.conversations USING bm25 ((search_text)) WITH (text_config='simple')
397
+ `)
398
+ .simple();
399
+ await sql
400
+ .unsafe(`
401
+ CREATE INDEX IF NOT EXISTS messages_search_bm25_idx
402
+ ON ai.messages USING bm25 ((search_text)) WITH (text_config='simple')
403
+ `)
404
+ .simple();
405
+ console.log(" ✓ ai conversation optional BM25 search indexes");
406
+ }
407
+ } catch (error) {
408
+ console.warn(" ! ai conversation optional BM25 search unavailable; native PostgreSQL FTS remains active", error);
409
+ }
410
+
411
+ await sql`
412
+ CREATE TABLE IF NOT EXISTS ai.turns (
413
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
414
+ short_id TEXT NOT NULL,
415
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
416
+ status TEXT NOT NULL DEFAULT 'queued',
417
+ model_profile_id TEXT,
418
+ attempt INTEGER NOT NULL DEFAULT 0,
419
+ live_blocks JSONB,
420
+ live_seq BIGINT NOT NULL DEFAULT 0,
421
+ deadline TIMESTAMPTZ,
422
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
423
+ completed_at TIMESTAMPTZ,
424
+ error TEXT,
425
+ cancel_requested_at TIMESTAMPTZ,
426
+ cancellation_reason TEXT,
427
+ lease_owner TEXT,
428
+ lease_expires_at TIMESTAMPTZ,
429
+ heartbeat_at TIMESTAMPTZ,
430
+ run_config JSONB,
431
+ CONSTRAINT ai_turns_status_check CHECK (status IN ('queued', 'running', 'waiting_for_action', 'completed', 'failed', 'aborted'))
432
+ )
433
+ `.simple();
434
+
435
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
436
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_conversation_short_id ON ai.turns(conversation_id, short_id)`.simple();
437
+ await backfillAiShortIds(
438
+ "idx_ai_turns_conversation_short_id",
439
+ await sql<{ id: string }[]>`SELECT id FROM ai.turns WHERE short_id IS NULL`,
440
+ (id, shortId) => sql`UPDATE ai.turns SET short_id = ${shortId} WHERE id = ${id}::uuid`,
441
+ );
442
+ await sql`ALTER TABLE ai.turns ALTER COLUMN short_id SET NOT NULL`.simple();
443
+
444
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ`.simple();
445
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS cancellation_reason TEXT`.simple();
446
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS lease_owner TEXT`.simple();
447
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ`.simple();
448
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ`.simple();
449
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS run_config JSONB`.simple();
450
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 0`.simple();
451
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_blocks JSONB`.simple();
452
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_seq BIGINT NOT NULL DEFAULT 0`.simple();
453
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS deadline TIMESTAMPTZ`.simple();
454
+ await sql`ALTER TABLE ai.turns ALTER COLUMN status SET DEFAULT 'queued'`.simple();
455
+ await sql`
456
+ UPDATE ai.turns
457
+ SET run_config = (run_config #>> '{}')::jsonb
458
+ WHERE jsonb_typeof(run_config) = 'string'
459
+ `.simple();
460
+ await sql`
461
+ UPDATE ai.turns
462
+ SET live_blocks = (live_blocks #>> '{}')::jsonb
463
+ WHERE jsonb_typeof(live_blocks) = 'string'
464
+ `.simple();
465
+
466
+ await sql`
467
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_one_active_per_conversation
468
+ ON ai.turns(conversation_id)
469
+ WHERE status IN ('queued', 'running', 'waiting_for_action')
470
+ `.simple();
471
+
472
+ await sql`
473
+ CREATE INDEX IF NOT EXISTS idx_ai_turns_conversation_created
474
+ ON ai.turns(conversation_id, created_at DESC)
475
+ `.simple();
476
+
477
+ await sql`
478
+ CREATE INDEX IF NOT EXISTS idx_ai_turns_completed_chat
479
+ ON ai.turns(completed_at, id)
480
+ WHERE status = 'completed' AND run_config->>'kind' = 'chat'
481
+ `.simple();
482
+
483
+ await sql`
484
+ CREATE TABLE IF NOT EXISTS ai.conversation_resource_refs (
485
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
486
+ resource_type TEXT NOT NULL,
487
+ resource_id TEXT NOT NULL,
488
+ title TEXT,
489
+ preview TEXT,
490
+ icon TEXT,
491
+ href TEXT,
492
+ source_turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
493
+ source_call_id TEXT,
494
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
495
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
496
+ PRIMARY KEY (conversation_id, resource_type, resource_id)
497
+ )
498
+ `.simple();
499
+ await sql`ALTER TABLE ai.conversation_resource_refs DROP COLUMN IF EXISTS occurrences`.simple();
500
+
501
+ await sql`
502
+ CREATE INDEX IF NOT EXISTS idx_ai_conversation_resource_refs_recent
503
+ ON ai.conversation_resource_refs(conversation_id, last_seen_at DESC, resource_type, resource_id)
504
+ `.simple();
505
+
506
+ await sql`
507
+ CREATE INDEX IF NOT EXISTS idx_ai_conversation_resource_refs_resource
508
+ ON ai.conversation_resource_refs(resource_type, resource_id, conversation_id)
509
+ `.simple();
510
+
511
+ await sql`
512
+ CREATE TABLE IF NOT EXISTS ai.conversation_sources (
513
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
514
+ kind TEXT NOT NULL,
515
+ source_key TEXT NOT NULL,
516
+ title TEXT NOT NULL,
517
+ preview TEXT,
518
+ icon TEXT,
519
+ href TEXT,
520
+ occurrences INTEGER NOT NULL DEFAULT 1,
521
+ source_turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
522
+ source_call_id TEXT,
523
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
524
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
525
+ PRIMARY KEY (conversation_id, kind, source_key),
526
+ CONSTRAINT ai_conversation_sources_kind_check CHECK (kind IN ('web', 'activity')),
527
+ CONSTRAINT ai_conversation_sources_occurrences_check CHECK (occurrences > 0)
528
+ )
529
+ `.simple();
530
+
531
+ await sql`
532
+ CREATE INDEX IF NOT EXISTS idx_ai_conversation_sources_recent
533
+ ON ai.conversation_sources(conversation_id, last_seen_at DESC, kind, source_key)
534
+ `.simple();
535
+
536
+ await sql`
537
+ CREATE TABLE IF NOT EXISTS ai.turn_steers (
538
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
539
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
540
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
541
+ seq INTEGER NOT NULL,
542
+ client_request_id TEXT NOT NULL,
543
+ text TEXT NOT NULL,
544
+ status TEXT NOT NULL DEFAULT 'pending',
545
+ message_id UUID REFERENCES ai.messages(id) ON DELETE SET NULL,
546
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
547
+ consumed_at TIMESTAMPTZ,
548
+ CONSTRAINT ai_turn_steers_status_check CHECK (status IN ('pending', 'consumed', 'discarded')),
549
+ CONSTRAINT ai_turn_steers_text_check CHECK (length(btrim(text)) BETWEEN 1 AND 20000)
550
+ )
551
+ `.simple();
552
+
553
+ await sql`
554
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turn_steers_turn_seq
555
+ ON ai.turn_steers(turn_id, seq)
556
+ `.simple();
557
+
558
+ await sql`
559
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turn_steers_request
560
+ ON ai.turn_steers(turn_id, client_request_id)
561
+ `.simple();
562
+
563
+ await sql`
564
+ CREATE INDEX IF NOT EXISTS idx_ai_turn_steers_pending
565
+ ON ai.turn_steers(conversation_id, turn_id, seq ASC)
566
+ WHERE status = 'pending'
567
+ `.simple();
568
+
569
+ // Streaming persistence moved to live_blocks snapshots plus the Redis fanout
570
+ // topic; the per-delta event log is gone.
571
+ await sql`DROP TABLE IF EXISTS ai.turn_events`.simple();
572
+
573
+ await sql`
574
+ CREATE TABLE IF NOT EXISTS ai.pending_actions (
575
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
576
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
577
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
578
+ call_id TEXT NOT NULL,
579
+ kind TEXT NOT NULL,
580
+ tool_name TEXT NOT NULL,
581
+ args JSONB NOT NULL,
582
+ message TEXT,
583
+ review JSONB,
584
+ approval_scope TEXT NOT NULL,
585
+ allow_always BOOLEAN NOT NULL DEFAULT FALSE,
586
+ frontend_mode TEXT,
587
+ status TEXT NOT NULL DEFAULT 'pending',
588
+ resolved_event JSONB,
589
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
590
+ resolved_at TIMESTAMPTZ,
591
+ CONSTRAINT ai_pending_actions_kind_check CHECK (kind IN ('approval', 'custom_approval', 'client_tool')),
592
+ CONSTRAINT ai_pending_actions_status_check CHECK (status IN ('pending', 'resolved', 'aborted'))
593
+ )
594
+ `.simple();
595
+
596
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
597
+
598
+ await sql`
599
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
600
+ ON ai.pending_actions(turn_id, call_id)
601
+ `.simple();
602
+
603
+ await sql`
604
+ CREATE INDEX IF NOT EXISTS idx_ai_pending_actions_pending_turn
605
+ ON ai.pending_actions(conversation_id, turn_id, created_at ASC)
606
+ WHERE status = 'pending'
607
+ `.simple();
608
+
609
+ await sql`
610
+ DO $$
611
+ BEGIN
612
+ IF EXISTS (
613
+ SELECT 1 FROM information_schema.columns
614
+ WHERE table_schema = 'ai' AND table_name = 'tool_approval_preferences' AND column_name = 'app_id'
615
+ ) THEN
616
+ DROP TABLE ai.tool_approval_preferences;
617
+ END IF;
618
+ END $$
619
+ `.simple();
620
+
621
+ await sql`
622
+ CREATE TABLE IF NOT EXISTS ai.tool_approval_preferences (
623
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
624
+ actor_user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
625
+ tool_name TEXT NOT NULL,
626
+ approval_scope TEXT NOT NULL,
627
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
628
+ last_used_at TIMESTAMPTZ,
629
+ expires_at TIMESTAMPTZ,
630
+ CONSTRAINT ai_tool_approval_preferences_unique UNIQUE (actor_user_id, tool_name, approval_scope)
631
+ )
632
+ `.simple();
633
+
634
+ // Remembered capability approvals use the same stable qualified identity as
635
+ // loaded tools. Revoke the rare hashed legacy identity rather than guessing.
636
+ await sql`
637
+ INSERT INTO ai.tool_approval_preferences (
638
+ id, actor_user_id, tool_name, approval_scope, created_at, last_used_at, expires_at
639
+ )
640
+ SELECT
641
+ gen_random_uuid(),
642
+ actor_user_id,
643
+ substring(tool_name FROM '^([a-z][a-z0-9-]*)__') || '.' ||
644
+ replace(
645
+ replace(regexp_replace(tool_name, '^[a-z][a-z0-9-]*__(query|action)__', ''), '_dot_', '.'),
646
+ '__',
647
+ '_'
648
+ ),
649
+ approval_scope,
650
+ created_at,
651
+ last_used_at,
652
+ expires_at
653
+ FROM ai.tool_approval_preferences
654
+ WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
655
+ AND NOT (length(tool_name) = 64 AND tool_name ~ '__[0-9a-f]{12}$')
656
+ ON CONFLICT (actor_user_id, tool_name, approval_scope) DO NOTHING
657
+ `.simple();
658
+ await sql`
659
+ DELETE FROM ai.tool_approval_preferences
660
+ WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
661
+ `.simple();
662
+
663
+ await sql`
664
+ CREATE TABLE IF NOT EXISTS ai.tool_calls (
665
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
666
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
667
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
668
+ call_id TEXT NOT NULL,
669
+ tool_name TEXT NOT NULL,
670
+ location TEXT NOT NULL DEFAULT 'server',
671
+ status TEXT NOT NULL DEFAULT 'pending',
672
+ approval_state TEXT NOT NULL DEFAULT 'not_required',
673
+ input_meta JSONB,
674
+ output_meta JSONB,
675
+ error TEXT,
676
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
677
+ started_at TIMESTAMPTZ,
678
+ approval_requested_at TIMESTAMPTZ,
679
+ approved_at TIMESTAMPTZ,
680
+ rejected_at TIMESTAMPTZ,
681
+ completed_at TIMESTAMPTZ,
682
+ CONSTRAINT ai_tool_calls_status_check CHECK (status IN ('pending', 'running', 'waiting_for_approval', 'waiting_for_frontend', 'completed', 'failed', 'rejected')),
683
+ CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_always', 'approved_by_preference', 'rejected'))
684
+ )
685
+ `.simple();
686
+
687
+ await sql`
688
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_turn_call
689
+ ON ai.tool_calls(turn_id, call_id)
690
+ `.simple();
691
+
692
+ await sql`
693
+ CREATE INDEX IF NOT EXISTS idx_ai_tool_calls_conversation_created
694
+ ON ai.tool_calls(conversation_id, created_at DESC)
695
+ `.simple();
696
+
697
+ await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
698
+ await sql`
699
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_idempotency_key
700
+ ON ai.tool_calls(idempotency_key)
701
+ WHERE idempotency_key IS NOT NULL
702
+ `.simple();
703
+
704
+ await sql`
705
+ CREATE TABLE IF NOT EXISTS ai.inter_chat_messages (
706
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
707
+ short_id TEXT NOT NULL,
708
+ source_conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
709
+ source_turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
710
+ source_call_id TEXT NOT NULL,
711
+ target_conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
712
+ actor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
713
+ text TEXT NOT NULL,
714
+ idempotency_key TEXT NOT NULL UNIQUE,
715
+ status TEXT NOT NULL DEFAULT 'pending',
716
+ target_turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
717
+ target_message_id UUID REFERENCES ai.messages(id) ON DELETE SET NULL,
718
+ error TEXT,
719
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
720
+ delivered_at TIMESTAMPTZ,
721
+ CONSTRAINT ai_inter_chat_messages_short_id_unique UNIQUE (short_id),
722
+ CONSTRAINT ai_inter_chat_messages_status_check CHECK (status IN ('pending', 'delivered', 'failed')),
723
+ CONSTRAINT ai_inter_chat_messages_text_check CHECK (length(btrim(text)) BETWEEN 1 AND 20000),
724
+ CONSTRAINT ai_inter_chat_messages_distinct_chats_check CHECK (source_conversation_id <> target_conversation_id)
725
+ )
726
+ `.simple();
727
+
728
+ await sql`
729
+ CREATE INDEX IF NOT EXISTS idx_ai_inter_chat_messages_pending_target
730
+ ON ai.inter_chat_messages(target_conversation_id, created_at ASC, id ASC)
731
+ WHERE status = 'pending'
732
+ `.simple();
733
+
734
+ await sql`
735
+ CREATE TABLE IF NOT EXISTS ai.chat_tasks (
736
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
737
+ short_id TEXT NOT NULL,
738
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
739
+ sponsor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
740
+ prompt TEXT NOT NULL,
741
+ schedule_kind TEXT NOT NULL,
742
+ run_at TIMESTAMPTZ,
743
+ cron TEXT,
744
+ timezone TEXT NOT NULL,
745
+ state TEXT NOT NULL DEFAULT 'active',
746
+ revision BIGINT NOT NULL DEFAULT 0,
747
+ mandate_id UUID,
748
+ mandate_revision BIGINT,
749
+ last_error TEXT,
750
+ idempotency_key TEXT,
751
+ idempotency_fingerprint TEXT,
752
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
753
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
754
+ CONSTRAINT ai_chat_tasks_short_id_unique UNIQUE (short_id),
755
+ CONSTRAINT ai_chat_tasks_prompt_check CHECK (length(btrim(prompt)) BETWEEN 1 AND 10000),
756
+ CONSTRAINT ai_chat_tasks_schedule_kind_check CHECK (schedule_kind IN ('once', 'cron')),
757
+ CONSTRAINT ai_chat_tasks_schedule_check CHECK (
758
+ (schedule_kind = 'once' AND run_at IS NOT NULL AND cron IS NULL) OR
759
+ (schedule_kind = 'cron' AND run_at IS NULL AND cron IS NOT NULL)
760
+ ),
761
+ CONSTRAINT ai_chat_tasks_state_check CHECK (state IN ('active', 'paused', 'completed', 'needs_attention'))
762
+ )
763
+ `.simple();
764
+
765
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS idempotency_fingerprint TEXT`.simple();
766
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0`.simple();
767
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_id UUID`.simple();
768
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_revision BIGINT`.simple();
769
+ await sql`
770
+ DO $$
771
+ BEGIN
772
+ IF EXISTS (
773
+ SELECT 1 FROM pg_constraint
774
+ WHERE conname = 'ai_chat_tasks_mandate_fkey' AND confdeltype <> 'n'
775
+ ) THEN
776
+ ALTER TABLE ai.chat_tasks DROP CONSTRAINT ai_chat_tasks_mandate_fkey;
777
+ END IF;
778
+ IF NOT EXISTS (
779
+ SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_mandate_fkey'
780
+ ) THEN
781
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_mandate_fkey
782
+ FOREIGN KEY (mandate_id) REFERENCES auth.mandates(id) ON DELETE SET NULL;
783
+ END IF;
784
+ END $$
785
+ `.simple();
786
+ // Existing tasks are upgraded under a row lock when first reconciled or run.
787
+ // Avoid an unbounded migration and never fall back to an interactive session.
788
+ await sql`ALTER TABLE ai.chat_tasks ALTER COLUMN mandate_id DROP NOT NULL`.simple();
789
+ await sql`ALTER TABLE ai.chat_tasks ALTER COLUMN mandate_revision DROP NOT NULL`.simple();
790
+ await sql`
791
+ CREATE OR REPLACE FUNCTION ai.clear_chat_task_mandate_revision() RETURNS trigger AS $$
792
+ BEGIN
793
+ IF NEW.mandate_id IS NULL THEN NEW.mandate_revision := NULL; END IF;
794
+ RETURN NEW;
795
+ END
796
+ $$ LANGUAGE plpgsql
797
+ `.simple();
798
+ await sql`DROP TRIGGER IF EXISTS ai_chat_task_mandate_clear ON ai.chat_tasks`.simple();
799
+ await sql`
800
+ CREATE TRIGGER ai_chat_task_mandate_clear
801
+ BEFORE UPDATE OF mandate_id ON ai.chat_tasks
802
+ FOR EACH ROW EXECUTE FUNCTION ai.clear_chat_task_mandate_revision()
803
+ `.simple();
804
+ await sql`
805
+ CREATE OR REPLACE FUNCTION ai.revoke_chat_task_mandate() RETURNS trigger AS $$
806
+ DECLARE
807
+ revoked_count INTEGER;
808
+ BEGIN
809
+ UPDATE auth.mandates
810
+ SET state = 'revoked', revision = revision + 1, updated_at = now(), revoked_at = now(),
811
+ revoked_by_app_id = 'core', revoke_reason = 'Scheduled chat task deleted'
812
+ WHERE id = OLD.mandate_id AND state <> 'revoked';
813
+ GET DIAGNOSTICS revoked_count = ROW_COUNT;
814
+ IF revoked_count = 1 THEN
815
+ INSERT INTO audit.events (
816
+ action, outcome, target_type, target_id, reason, metadata
817
+ ) VALUES (
818
+ 'mandate.revoke', 'allowed', 'mandate', OLD.mandate_id::text,
819
+ 'Scheduled chat task deleted',
820
+ jsonb_build_object(
821
+ 'ownerAppId', 'core',
822
+ 'workloadType', 'ai.chat-task',
823
+ 'workloadId', OLD.id::text,
824
+ 'provenance', 'ai.chat-task-cascade'
825
+ )
826
+ );
827
+ END IF;
828
+ RETURN OLD;
829
+ END
830
+ $$ LANGUAGE plpgsql
831
+ `.simple();
832
+ await sql`DROP TRIGGER IF EXISTS ai_chat_task_mandate_revoke ON ai.chat_tasks`.simple();
833
+ await sql`
834
+ CREATE TRIGGER ai_chat_task_mandate_revoke
835
+ BEFORE DELETE ON ai.chat_tasks
836
+ FOR EACH ROW EXECUTE FUNCTION ai.revoke_chat_task_mandate()
837
+ `.simple();
838
+ await sql`ALTER TABLE ai.chat_tasks DROP CONSTRAINT IF EXISTS chat_tasks_idempotency_key_key`.simple();
839
+ await sql`
840
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_tasks_owner_idempotency
841
+ ON ai.chat_tasks(sponsor_user_id, idempotency_key)
842
+ WHERE idempotency_key IS NOT NULL
843
+ `.simple();
844
+
845
+ await sql`
846
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_tasks_owner_created
847
+ ON ai.chat_tasks(sponsor_user_id, created_at DESC, id DESC)
848
+ `.simple();
849
+
850
+ await sql`
851
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_tasks_due_once
852
+ ON ai.chat_tasks(run_at ASC, id ASC)
853
+ WHERE state = 'active' AND schedule_kind = 'once'
854
+ `.simple();
855
+
856
+ await sql`
857
+ CREATE TABLE IF NOT EXISTS ai.chat_task_occurrences (
858
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
859
+ short_id TEXT NOT NULL,
860
+ task_id UUID NOT NULL REFERENCES ai.chat_tasks(id) ON DELETE CASCADE,
861
+ scheduled_for TIMESTAMPTZ NOT NULL,
862
+ trigger TEXT NOT NULL DEFAULT 'scheduled',
863
+ state TEXT NOT NULL DEFAULT 'queued',
864
+ task_revision BIGINT NOT NULL DEFAULT 0,
865
+ request_key TEXT NOT NULL,
866
+ turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
867
+ error TEXT,
868
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
869
+ started_at TIMESTAMPTZ,
870
+ completed_at TIMESTAMPTZ,
871
+ CONSTRAINT ai_chat_task_occurrences_short_id_unique UNIQUE (short_id),
872
+ CONSTRAINT ai_chat_task_occurrences_slot_unique UNIQUE (task_id, scheduled_for),
873
+ CONSTRAINT ai_chat_task_occurrences_trigger_check CHECK (trigger IN ('scheduled', 'manual')),
874
+ CONSTRAINT ai_chat_task_occurrences_state_check CHECK (state IN ('queued', 'running', 'completed', 'failed'))
875
+ )
876
+ `.simple();
877
+
878
+ await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS task_revision BIGINT NOT NULL DEFAULT 0`.simple();
879
+
880
+ await sql`
881
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_request_key
882
+ ON ai.chat_task_occurrences(request_key)
883
+ `.simple();
884
+
885
+ await sql`
886
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_queued
887
+ ON ai.chat_task_occurrences(created_at ASC, id ASC)
888
+ WHERE state = 'queued'
889
+ `.simple();
890
+
891
+ await sql`
892
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_active_task
893
+ ON ai.chat_task_occurrences(task_id)
894
+ WHERE state IN ('queued', 'running')
895
+ `.simple();
896
+
897
+ await sql`
898
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_task_created
899
+ ON ai.chat_task_occurrences(task_id, created_at DESC, id DESC)
900
+ `.simple();
901
+
902
+ await sql`
903
+ CREATE TABLE IF NOT EXISTS ai.user_prefs (
904
+ user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
905
+ memory_enabled BOOLEAN NOT NULL DEFAULT TRUE,
906
+ memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE,
907
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
908
+ )
909
+ `.simple();
910
+
911
+ // Unreleased alpha cut: structured records replace the former free-form
912
+ // memory blob. Deliberately discard it instead of migrating ambiguous lines.
913
+ await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS memory`.simple();
914
+ await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS instructions`.simple();
915
+ await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE`.simple();
916
+
917
+ // Last model the user actually ran a turn with — preselected for new chats.
918
+ await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS last_model_id TEXT NOT NULL DEFAULT ''`.simple();
919
+
920
+ await sql`
921
+ CREATE TABLE IF NOT EXISTS ai.memories (
922
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
923
+ short_id TEXT NOT NULL,
924
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
925
+ kind TEXT NOT NULL,
926
+ content TEXT NOT NULL,
927
+ priority TEXT NOT NULL DEFAULT 'normal',
928
+ source TEXT NOT NULL DEFAULT 'user',
929
+ source_conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
930
+ source_message_id UUID REFERENCES ai.messages(id) ON DELETE SET NULL,
931
+ resource_type TEXT,
932
+ resource_id TEXT,
933
+ superseded_by_id UUID REFERENCES ai.memories(id) ON DELETE SET NULL,
934
+ deleted_at TIMESTAMPTZ,
935
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
936
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
937
+ search_document TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(content, ''))) STORED,
938
+ CONSTRAINT ai_memories_kind_check CHECK (kind IN ('fact', 'preference', 'workflow')),
939
+ CONSTRAINT ai_memories_priority_check CHECK (priority IN ('normal', 'pinned')),
940
+ CONSTRAINT ai_memories_source_check CHECK (source IN ('user', 'agent', 'background')),
941
+ CONSTRAINT ai_memories_content_check CHECK (char_length(content) BETWEEN 1 AND 500),
942
+ CONSTRAINT ai_memories_resource_value_check CHECK (
943
+ (resource_type IS NULL AND resource_id IS NULL)
944
+ OR (char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512)
945
+ ),
946
+ CONSTRAINT ai_memories_workflow_resource_check CHECK (
947
+ (kind = 'workflow' AND resource_type IS NOT NULL AND resource_id IS NOT NULL)
948
+ OR (kind <> 'workflow' AND resource_type IS NULL AND resource_id IS NULL)
949
+ )
950
+ )
951
+ `.simple();
952
+
953
+ await sql`ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
954
+ await sql`ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS resource_type TEXT`.simple();
955
+ await sql`ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS resource_id TEXT`.simple();
956
+ await sql`ALTER TABLE ai.memories DROP CONSTRAINT IF EXISTS ai_memories_resource_value_check`.simple();
957
+ await sql`
958
+ ALTER TABLE ai.memories ADD CONSTRAINT ai_memories_resource_value_check CHECK (
959
+ (resource_type IS NULL AND resource_id IS NULL)
960
+ OR (char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512)
961
+ )
962
+ `.simple();
963
+ await sql`ALTER TABLE ai.memories DROP CONSTRAINT IF EXISTS ai_memories_kind_check`.simple();
964
+ await sql`ALTER TABLE ai.memories ADD CONSTRAINT ai_memories_kind_check CHECK (kind IN ('fact', 'preference', 'workflow'))`.simple();
965
+ await sql`ALTER TABLE ai.memories DROP CONSTRAINT IF EXISTS ai_memories_workflow_resource_check`.simple();
966
+ await sql`
967
+ ALTER TABLE ai.memories ADD CONSTRAINT ai_memories_workflow_resource_check CHECK (
968
+ (kind = 'workflow' AND resource_type IS NOT NULL AND resource_id IS NOT NULL)
969
+ OR (kind <> 'workflow' AND resource_type IS NULL AND resource_id IS NULL)
970
+ )
971
+ `.simple();
972
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_short_id ON ai.memories(short_id)`.simple();
973
+ await backfillAiShortIds(
974
+ "idx_ai_memories_short_id",
975
+ await sql<{ id: string }[]>`SELECT id FROM ai.memories WHERE short_id IS NULL`,
976
+ (id, shortId) => sql`UPDATE ai.memories SET short_id = ${shortId} WHERE id = ${id}::uuid`,
977
+ );
978
+ await sql`ALTER TABLE ai.memories ALTER COLUMN short_id SET NOT NULL`.simple();
979
+
980
+ await sql`
981
+ CREATE INDEX IF NOT EXISTS idx_ai_memories_user_active
982
+ ON ai.memories(user_id, priority DESC, updated_at DESC, id)
983
+ WHERE deleted_at IS NULL AND superseded_by_id IS NULL
984
+ `.simple();
985
+ await sql`
986
+ CREATE INDEX IF NOT EXISTS idx_ai_memories_search_document
987
+ ON ai.memories USING GIN(search_document)
988
+ `.simple();
989
+ await sql`
990
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_user_content_active
991
+ ON ai.memories(user_id, lower(content))
992
+ WHERE deleted_at IS NULL AND superseded_by_id IS NULL
993
+ `.simple();
994
+
995
+ // pg_textsearch only improves ranking. Native GIN FTS above remains the
996
+ // correctness baseline and optional setup must never block AI startup.
997
+ try {
998
+ const [extension] = await sql<{ available: boolean; installed: boolean; server_version: number }[]>`
999
+ SELECT
1000
+ EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_textsearch') AS available,
1001
+ EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed,
1002
+ current_setting('server_version_num')::int AS server_version
1003
+ `;
1004
+ let installed = extension?.installed ?? false;
1005
+ if (!installed && extension?.available && extension.server_version >= 170000) {
1006
+ await sql`CREATE EXTENSION IF NOT EXISTS pg_textsearch`.simple();
1007
+ installed = true;
1008
+ }
1009
+ if (installed) {
1010
+ await sql
1011
+ .unsafe(`
1012
+ CREATE INDEX IF NOT EXISTS memories_search_bm25_idx
1013
+ ON ai.memories USING bm25 ((content)) WITH (text_config='simple')
1014
+ `)
1015
+ .simple();
1016
+ console.log(" ✓ ai.memories optional BM25 search index");
1017
+ }
1018
+ } catch (error) {
1019
+ console.warn(" ! ai.memories optional BM25 search index unavailable; native PostgreSQL FTS remains active", error);
1020
+ }
1021
+
1022
+ // Turn-level learning avoids replaying an entire conversation whenever an
1023
+ // Assistant or tool message updates the chat. Existing turns are marked as
1024
+ // already considered exactly once when the column is introduced.
1025
+ await sql`
1026
+ DO $$
1027
+ BEGIN
1028
+ IF NOT EXISTS (
1029
+ SELECT 1 FROM information_schema.columns
1030
+ WHERE table_schema = 'ai' AND table_name = 'turns' AND column_name = 'memory_learned_at'
1031
+ ) THEN
1032
+ ALTER TABLE ai.turns ADD COLUMN memory_learned_at TIMESTAMPTZ;
1033
+ UPDATE ai.turns SET memory_learned_at = COALESCE(completed_at, created_at)
1034
+ WHERE status IN ('completed', 'failed', 'aborted');
1035
+ END IF;
1036
+ END $$
1037
+ `.simple();
1038
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS memory_learn_failed_at TIMESTAMPTZ`.simple();
1039
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS memory_learn_fail_count INTEGER NOT NULL DEFAULT 0`.simple();
1040
+ await sql`
1041
+ CREATE INDEX IF NOT EXISTS idx_ai_turns_memory_learning_candidates
1042
+ ON ai.turns(completed_at, id)
1043
+ WHERE status = 'completed' AND memory_learned_at IS NULL
1044
+ `.simple();
1045
+
1046
+ await sql`
1047
+ CREATE TABLE IF NOT EXISTS ai.memory_workflow_evidence (
1048
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
1049
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
1050
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
1051
+ capability_id TEXT NOT NULL,
1052
+ resource_type TEXT NOT NULL,
1053
+ resource_id TEXT NOT NULL,
1054
+ resource_title TEXT,
1055
+ observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1056
+ reviewed_at TIMESTAMPTZ,
1057
+ PRIMARY KEY (turn_id, capability_id, resource_type, resource_id),
1058
+ CONSTRAINT ai_memory_workflow_evidence_capability_check CHECK (char_length(capability_id) BETWEEN 3 AND 180),
1059
+ CONSTRAINT ai_memory_workflow_evidence_resource_check CHECK (
1060
+ char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512
1061
+ )
1062
+ )
1063
+ `.simple();
1064
+ await sql`ALTER TABLE ai.memory_workflow_evidence DROP CONSTRAINT IF EXISTS ai_memory_workflow_evidence_capability_check`.simple();
1065
+ await sql`
1066
+ ALTER TABLE ai.memory_workflow_evidence ADD CONSTRAINT ai_memory_workflow_evidence_capability_check
1067
+ CHECK (char_length(capability_id) BETWEEN 3 AND 180)
1068
+ `.simple();
1069
+ await sql`ALTER TABLE ai.memory_workflow_evidence DROP CONSTRAINT IF EXISTS ai_memory_workflow_evidence_resource_check`.simple();
1070
+ await sql`
1071
+ ALTER TABLE ai.memory_workflow_evidence ADD CONSTRAINT ai_memory_workflow_evidence_resource_check
1072
+ CHECK (char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512)
1073
+ `.simple();
1074
+ await sql`
1075
+ CREATE INDEX IF NOT EXISTS idx_ai_memory_workflow_evidence_pending
1076
+ ON ai.memory_workflow_evidence(user_id, capability_id, resource_type, resource_id, observed_at)
1077
+ WHERE reviewed_at IS NULL
1078
+ `.simple();
1079
+
1080
+ await sql`
1081
+ CREATE TABLE IF NOT EXISTS ai.memory_learning_runs (
1082
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1083
+ short_id TEXT NOT NULL,
1084
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
1085
+ conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
1086
+ turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
1087
+ conversation_title TEXT NOT NULL,
1088
+ run_kind TEXT NOT NULL DEFAULT 'turn',
1089
+ evidence_key TEXT,
1090
+ status TEXT NOT NULL DEFAULT 'running',
1091
+ model_profile_id TEXT,
1092
+ duration_ms INTEGER,
1093
+ input_tokens INTEGER,
1094
+ output_tokens INTEGER,
1095
+ accounted_tokens INTEGER NOT NULL DEFAULT 0,
1096
+ added_count INTEGER NOT NULL DEFAULT 0,
1097
+ updated_count INTEGER NOT NULL DEFAULT 0,
1098
+ merged_count INTEGER NOT NULL DEFAULT 0,
1099
+ retired_count INTEGER NOT NULL DEFAULT 0,
1100
+ changes JSONB NOT NULL DEFAULT '[]'::jsonb,
1101
+ error TEXT,
1102
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1103
+ completed_at TIMESTAMPTZ,
1104
+ CONSTRAINT ai_memory_learning_runs_status_check CHECK (status IN ('running', 'ok', 'skipped', 'failed')),
1105
+ CONSTRAINT ai_memory_learning_runs_kind_check CHECK (run_kind IN ('turn', 'workflow')),
1106
+ CONSTRAINT ai_memory_learning_runs_evidence_check CHECK (
1107
+ (run_kind = 'turn' AND evidence_key IS NULL)
1108
+ OR (run_kind = 'workflow' AND turn_id IS NOT NULL AND evidence_key IS NOT NULL)
1109
+ ),
1110
+ CONSTRAINT ai_memory_learning_runs_changes_check CHECK (jsonb_typeof(changes) = 'array')
1111
+ )
1112
+ `.simple();
1113
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL`.simple();
1114
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS run_kind TEXT NOT NULL DEFAULT 'turn'`.simple();
1115
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS evidence_key TEXT`.simple();
1116
+ await sql`
1117
+ UPDATE ai.memory_learning_runs
1118
+ SET evidence_key = 'legacy:' || id::text
1119
+ WHERE run_kind = 'workflow' AND evidence_key IS NULL
1120
+ `.simple();
1121
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS input_tokens INTEGER`.simple();
1122
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS output_tokens INTEGER`.simple();
1123
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS accounted_tokens INTEGER NOT NULL DEFAULT 0`.simple();
1124
+ await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS retired_count INTEGER NOT NULL DEFAULT 0`.simple();
1125
+ await sql`ALTER TABLE ai.memory_learning_runs DROP CONSTRAINT IF EXISTS ai_memory_learning_runs_kind_check`.simple();
1126
+ await sql`
1127
+ ALTER TABLE ai.memory_learning_runs ADD CONSTRAINT ai_memory_learning_runs_kind_check
1128
+ CHECK (run_kind IN ('turn', 'workflow'))
1129
+ `.simple();
1130
+ await sql`ALTER TABLE ai.memory_learning_runs DROP CONSTRAINT IF EXISTS ai_memory_learning_runs_evidence_check`.simple();
1131
+ await sql`
1132
+ ALTER TABLE ai.memory_learning_runs ADD CONSTRAINT ai_memory_learning_runs_evidence_check CHECK (
1133
+ (run_kind = 'turn' AND evidence_key IS NULL)
1134
+ OR (run_kind = 'workflow' AND turn_id IS NOT NULL AND evidence_key IS NOT NULL)
1135
+ )
1136
+ `.simple();
1137
+ await sql`
1138
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_short_id
1139
+ ON ai.memory_learning_runs(short_id)
1140
+ `.simple();
1141
+ await sql`
1142
+ CREATE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_user_created
1143
+ ON ai.memory_learning_runs(user_id, created_at DESC, id DESC)
1144
+ `.simple();
1145
+ await sql`
1146
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_turn
1147
+ ON ai.memory_learning_runs(turn_id)
1148
+ WHERE turn_id IS NOT NULL AND run_kind = 'turn'
1149
+ `.simple();
1150
+ await sql`
1151
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_workflow_evidence
1152
+ ON ai.memory_learning_runs(evidence_key)
1153
+ WHERE evidence_key IS NOT NULL AND run_kind = 'workflow'
1154
+ `.simple();
1155
+
1156
+ // ── Conversation file workspace ───────────────────────────────────
1157
+ await sql`
1158
+ CREATE TABLE IF NOT EXISTS ai.files (
1159
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1160
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
1161
+ path TEXT NOT NULL,
1162
+ bytes BYTEA NOT NULL,
1163
+ media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
1164
+ size INTEGER NOT NULL,
1165
+ origin TEXT NOT NULL DEFAULT 'assistant' CONSTRAINT ai_files_origin_check CHECK (origin IN ('user', 'assistant')),
1166
+ version BIGINT NOT NULL DEFAULT 1,
1167
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1168
+ CONSTRAINT ai_files_path_unique UNIQUE (conversation_id, path)
1169
+ )
1170
+ `.simple();
1171
+ await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT 'assistant'`.simple();
1172
+ await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1`.simple();
1173
+ await sql`UPDATE ai.files SET origin = 'user' WHERE (path = '/input' OR path LIKE '/input/%') AND origin <> 'user'`.simple();
1174
+ await sql`
1175
+ DO $$ BEGIN
1176
+ IF NOT EXISTS (
1177
+ SELECT 1 FROM pg_constraint WHERE conname = 'ai_files_origin_check' AND conrelid = 'ai.files'::regclass
1178
+ ) THEN
1179
+ ALTER TABLE ai.files ADD CONSTRAINT ai_files_origin_check CHECK (origin IN ('user', 'assistant'));
1180
+ END IF;
1181
+ END $$
1182
+ `.simple();
1183
+
1184
+ // Inline image parts were an alpha transport detail. Move their bytes into
1185
+ // the authorized conversation file store and leave only stable references in
1186
+ // message history. Re-running is idempotent because the path is derived from
1187
+ // the message short id and content position.
1188
+ await sql`
1189
+ WITH inline_images AS (
1190
+ SELECT
1191
+ m.id AS message_id,
1192
+ m.conversation_id,
1193
+ part.ordinality,
1194
+ part.value,
1195
+ lower(part.value->>'mediaType') AS media_type,
1196
+ decode(part.value->>'data', 'base64') AS bytes,
1197
+ '/image-' || m.short_id || '-' || part.ordinality ||
1198
+ CASE lower(part.value->>'mediaType')
1199
+ WHEN 'image/jpeg' THEN '.jpg'
1200
+ WHEN 'image/jpg' THEN '.jpg'
1201
+ WHEN 'image/png' THEN '.png'
1202
+ WHEN 'image/webp' THEN '.webp'
1203
+ WHEN 'image/gif' THEN '.gif'
1204
+ END AS path
1205
+ FROM ai.messages m
1206
+ CROSS JOIN LATERAL jsonb_array_elements(
1207
+ (CASE WHEN jsonb_typeof(m.message) = 'string' THEN (m.message #>> '{}')::jsonb ELSE m.message END)->'content'
1208
+ ) WITH ORDINALITY AS part(value, ordinality)
1209
+ WHERE m.role = 'user'
1210
+ AND part.value->>'type' = 'file'
1211
+ AND lower(part.value->>'mediaType') IN ('image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif')
1212
+ AND part.value->>'data' ~ '^[A-Za-z0-9+/]*={0,2}$'
1213
+ AND length(part.value->>'data') % 4 = 0
1214
+ ), migrated AS (
1215
+ INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin)
1216
+ SELECT conversation_id, path, bytes, media_type, octet_length(bytes), 'user'
1217
+ FROM inline_images
1218
+ ON CONFLICT (conversation_id, path) DO UPDATE SET updated_at = ai.files.updated_at
1219
+ WHERE ai.files.bytes = EXCLUDED.bytes
1220
+ AND ai.files.media_type = EXCLUDED.media_type
1221
+ AND ai.files.size = EXCLUDED.size
1222
+ AND ai.files.origin = 'user'
1223
+ RETURNING conversation_id, path
1224
+ )
1225
+ UPDATE ai.messages m
1226
+ SET message = CASE
1227
+ WHEN jsonb_typeof(m.message) = 'string' THEN to_jsonb((jsonb_set(
1228
+ (m.message #>> '{}')::jsonb,
1229
+ '{content}',
1230
+ (
1231
+ SELECT jsonb_agg(
1232
+ CASE
1233
+ WHEN image.path IS NULL THEN part.value
1234
+ ELSE jsonb_build_object(
1235
+ 'type', 'text',
1236
+ 'text', '<attachment path="' || image.path || '" media-type="' || image.media_type || '" size="' || octet_length(image.bytes) || '" />'
1237
+ )
1238
+ END
1239
+ ORDER BY part.ordinality
1240
+ )
1241
+ FROM jsonb_array_elements((m.message #>> '{}')::jsonb->'content') WITH ORDINALITY AS part(value, ordinality)
1242
+ LEFT JOIN inline_images image ON image.message_id = m.id AND image.ordinality = part.ordinality
1243
+ )
1244
+ ))::text)
1245
+ ELSE jsonb_set(
1246
+ m.message,
1247
+ '{content}',
1248
+ (
1249
+ SELECT jsonb_agg(
1250
+ CASE
1251
+ WHEN image.path IS NULL THEN part.value
1252
+ ELSE jsonb_build_object(
1253
+ 'type', 'text',
1254
+ 'text', '<attachment path="' || image.path || '" media-type="' || image.media_type || '" size="' || octet_length(image.bytes) || '" />'
1255
+ )
1256
+ END
1257
+ ORDER BY part.ordinality
1258
+ )
1259
+ FROM jsonb_array_elements(m.message->'content') WITH ORDINALITY AS part(value, ordinality)
1260
+ LEFT JOIN inline_images image ON image.message_id = m.id AND image.ordinality = part.ordinality
1261
+ )
1262
+ )
1263
+ END
1264
+ WHERE EXISTS (SELECT 1 FROM inline_images image WHERE image.message_id = m.id)
1265
+ AND NOT EXISTS (
1266
+ SELECT 1 FROM inline_images image
1267
+ WHERE image.message_id = m.id
1268
+ AND NOT EXISTS (
1269
+ SELECT 1 FROM migrated
1270
+ WHERE migrated.conversation_id = image.conversation_id AND migrated.path = image.path
1271
+ )
1272
+ )
1273
+ `.simple();
1274
+ await sql`
1275
+ DO $$ BEGIN
1276
+ IF EXISTS (
1277
+ SELECT 1
1278
+ FROM ai.messages m
1279
+ CROSS JOIN LATERAL jsonb_array_elements(
1280
+ (CASE WHEN jsonb_typeof(m.message) = 'string' THEN (m.message #>> '{}')::jsonb ELSE m.message END)->'content'
1281
+ ) AS part(value)
1282
+ WHERE m.role = 'user'
1283
+ AND part.value->>'type' = 'file'
1284
+ AND lower(part.value->>'mediaType') IN ('image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif')
1285
+ AND part.value->>'data' IS NOT NULL
1286
+ ) THEN
1287
+ RAISE EXCEPTION 'Could not migrate every historical inline AI image without a file collision';
1288
+ END IF;
1289
+ END $$
1290
+ `.simple();
1291
+ await sql`
1292
+ WITH referenced_parts AS (
1293
+ SELECT m.loop_id::uuid AS turn_id, part.ordinality, part.value
1294
+ FROM ai.messages m
1295
+ CROSS JOIN LATERAL jsonb_array_elements(
1296
+ (CASE WHEN jsonb_typeof(m.message) = 'string' THEN (m.message #>> '{}')::jsonb ELSE m.message END)->'content'
1297
+ ) WITH ORDINALITY AS part(value, ordinality)
1298
+ WHERE m.role = 'user'
1299
+ AND m.loop_id IS NOT NULL
1300
+ AND part.value->>'type' = 'text'
1301
+ AND part.value->>'text' LIKE '<attachment path=%'
1302
+ ), rewritten AS (
1303
+ SELECT
1304
+ t.id,
1305
+ jsonb_set(
1306
+ config.value,
1307
+ '{input}',
1308
+ jsonb_agg(
1309
+ CASE WHEN part.value->>'type' = 'file' AND reference.value IS NOT NULL THEN reference.value ELSE part.value END
1310
+ ORDER BY part.ordinality
1311
+ )
1312
+ ) AS value,
1313
+ jsonb_typeof(t.run_config) = 'string' AS string_encoded
1314
+ FROM ai.turns t
1315
+ CROSS JOIN LATERAL (
1316
+ SELECT CASE WHEN jsonb_typeof(t.run_config) = 'string' THEN (t.run_config #>> '{}')::jsonb ELSE t.run_config END AS value
1317
+ ) AS config
1318
+ CROSS JOIN LATERAL jsonb_array_elements(
1319
+ CASE WHEN jsonb_typeof(config.value->'input') = 'array' THEN config.value->'input' ELSE '[]'::jsonb END
1320
+ ) WITH ORDINALITY AS part(value, ordinality)
1321
+ LEFT JOIN referenced_parts reference ON reference.turn_id = t.id AND reference.ordinality = part.ordinality
1322
+ WHERE t.run_config IS NOT NULL
1323
+ AND t.status NOT IN ('queued', 'running', 'waiting_for_action')
1324
+ GROUP BY t.id, t.run_config, config.value
1325
+ HAVING bool_or(part.value->>'type' = 'file' AND reference.value IS NOT NULL)
1326
+ )
1327
+ UPDATE ai.turns t
1328
+ SET run_config = CASE WHEN rewritten.string_encoded THEN to_jsonb(rewritten.value::text) ELSE rewritten.value END
1329
+ FROM rewritten
1330
+ WHERE rewritten.id = t.id
1331
+ `.simple();
1332
+ // EXTERNAL keeps bytea un-compressed in TOAST so substring() slices read
1333
+ // only the needed chunks — head/tail on big files must not load everything.
1334
+ await sql`ALTER TABLE ai.files ALTER COLUMN bytes SET STORAGE EXTERNAL`.simple();
1335
+ await sql`
1336
+ CREATE TABLE IF NOT EXISTS ai.turn_files (
1337
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
1338
+ path TEXT NOT NULL,
1339
+ bytes BYTEA NOT NULL,
1340
+ media_type TEXT NOT NULL,
1341
+ size INTEGER NOT NULL,
1342
+ origin TEXT NOT NULL CONSTRAINT ai_turn_files_origin_check CHECK (origin IN ('user', 'assistant')),
1343
+ updated_at TIMESTAMPTZ NOT NULL,
1344
+ version BIGINT NOT NULL,
1345
+ PRIMARY KEY (turn_id, path)
1346
+ )
1347
+ `.simple();
1348
+ await sql`ALTER TABLE ai.turn_files ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1`.simple();
1349
+ await sql`ALTER TABLE ai.turn_files ALTER COLUMN bytes SET STORAGE EXTERNAL`.simple();
1350
+
1351
+ // Skills were previously alpha-only and used personal/workspace ownership.
1352
+ // Make the one-time model cut to permission-owned Agent Skills, then keep the
1353
+ // new schema intact on later migrations.
1354
+ await sql`
1355
+ DO $$
1356
+ BEGIN
1357
+ IF EXISTS (
1358
+ SELECT 1 FROM information_schema.columns
1359
+ WHERE table_schema = 'ai' AND table_name = 'skills' AND column_name IN ('scope', 'owner_user_id')
1360
+ ) THEN
1361
+ IF to_regclass('ai.skill_access') IS NOT NULL THEN
1362
+ DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.skill_access);
1363
+ END IF;
1364
+ DROP TABLE IF EXISTS ai.skill_seeds CASCADE;
1365
+ DROP TABLE IF EXISTS ai.skill_access CASCADE;
1366
+ DROP TABLE IF EXISTS ai.skill_user_state CASCADE;
1367
+ DROP TABLE IF EXISTS ai.skill_files CASCADE;
1368
+ DROP TABLE IF EXISTS ai.skill_events CASCADE;
1369
+ DROP TABLE IF EXISTS ai.skills CASCADE;
1370
+ END IF;
1371
+ END $$
1372
+ `.simple();
1373
+ await sql`DROP TABLE IF EXISTS ai.skill_user_state CASCADE`.simple();
1374
+ await sql`DROP TABLE IF EXISTS ai.skill_files CASCADE`.simple();
1375
+ await sql`DROP TABLE IF EXISTS ai.skill_events CASCADE`.simple();
1376
+
1377
+ await sql`
1378
+ CREATE TABLE IF NOT EXISTS ai.skills (
1379
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1380
+ short_id TEXT NOT NULL,
1381
+ name TEXT NOT NULL,
1382
+ description TEXT NOT NULL,
1383
+ instructions TEXT NOT NULL,
1384
+ extra_frontmatter JSONB NOT NULL DEFAULT '{}'::jsonb,
1385
+ revision INTEGER NOT NULL DEFAULT 1,
1386
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1387
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1388
+ CONSTRAINT ai_skills_name_check CHECK (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(name) <= 64),
1389
+ CONSTRAINT ai_skills_description_check CHECK (length(btrim(description)) BETWEEN 1 AND 1024),
1390
+ CONSTRAINT ai_skills_instructions_check CHECK (length(btrim(instructions)) BETWEEN 1 AND 100000),
1391
+ CONSTRAINT ai_skills_extra_frontmatter_check CHECK (jsonb_typeof(extra_frontmatter) = 'object')
1392
+ )
1393
+ `.simple();
1394
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_short_id ON ai.skills(short_id)`.simple();
1395
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
1396
+ await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
1397
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_managed_key ON ai.skills(managed_key) WHERE managed_key IS NOT NULL`.simple();
1398
+ await sql`
1399
+ CREATE TABLE IF NOT EXISTS ai.skill_seeds (
1400
+ key TEXT PRIMARY KEY,
1401
+ seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
1402
+ )
1403
+ `.simple();
1404
+ await backfillAiShortIds(
1405
+ "idx_ai_skills_short_id",
1406
+ await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
1407
+ (id, shortId) => sql`UPDATE ai.skills SET short_id = ${shortId} WHERE id = ${id}::uuid`,
1408
+ );
1409
+ await sql`ALTER TABLE ai.skills ALTER COLUMN short_id SET NOT NULL`.simple();
1410
+
1411
+ await sql`
1412
+ CREATE TABLE IF NOT EXISTS ai.skill_access (
1413
+ skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
1414
+ access_id UUID NOT NULL REFERENCES auth.access(id) ON DELETE CASCADE,
1415
+ short_id TEXT NOT NULL,
1416
+ PRIMARY KEY (skill_id, access_id)
1417
+ )
1418
+ `.simple();
1419
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skill_access_short_id ON ai.skill_access(skill_id, short_id)`.simple();
1420
+ for (const row of await sql<
1421
+ { skill_id: string; access_id: string }[]
1422
+ >`SELECT skill_id, access_id FROM ai.skill_access WHERE short_id IS NULL`) {
1423
+ await withAiShortId(
1424
+ "idx_ai_skill_access_short_id",
1425
+ (shortId) => sql`
1426
+ UPDATE ai.skill_access SET short_id = ${shortId}
1427
+ WHERE skill_id = ${row.skill_id}::uuid AND access_id = ${row.access_id}::uuid
1428
+ `,
1429
+ );
1430
+ }
1431
+ await sql`ALTER TABLE ai.skill_access ALTER COLUMN short_id SET NOT NULL`.simple();
1432
+
1433
+ await sql`
1434
+ CREATE TABLE IF NOT EXISTS ai.skill_user_disabled (
1435
+ skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
1436
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
1437
+ disabled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1438
+ PRIMARY KEY (skill_id, user_id)
1439
+ )
1440
+ `.simple();
1441
+ await sql`CREATE INDEX IF NOT EXISTS idx_ai_skill_user_disabled_user ON ai.skill_user_disabled(user_id)`.simple();
1442
+
1443
+ await sql`
1444
+ CREATE TABLE IF NOT EXISTS ai.skill_references (
1445
+ skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
1446
+ path TEXT NOT NULL,
1447
+ content TEXT NOT NULL,
1448
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1449
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1450
+ PRIMARY KEY (skill_id, path),
1451
+ CONSTRAINT ai_skill_references_path_check CHECK (
1452
+ path ~ '^references/[a-z0-9][a-z0-9._-]*\\.md$' AND length(path) <= 200
1453
+ ),
1454
+ CONSTRAINT ai_skill_references_content_check CHECK (length(content) <= 100000)
1455
+ )
1456
+ `.simple();
1457
+
1458
+ await sql`
1459
+ CREATE TABLE IF NOT EXISTS ai.turn_skill_snapshots (
1460
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
1461
+ skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
1462
+ skill_name TEXT NOT NULL,
1463
+ description TEXT NOT NULL,
1464
+ revision INTEGER NOT NULL,
1465
+ instructions TEXT NOT NULL,
1466
+ files JSONB NOT NULL,
1467
+ loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1468
+ PRIMARY KEY (turn_id, skill_id),
1469
+ UNIQUE (turn_id, skill_name),
1470
+ CONSTRAINT ai_turn_skill_snapshots_files_check CHECK (jsonb_typeof(files) = 'array')
1471
+ )
1472
+ `.simple();
1473
+
1474
+ await sql`
1475
+ CREATE TABLE IF NOT EXISTS ai.projects (
1476
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1477
+ short_id TEXT NOT NULL,
1478
+ name TEXT NOT NULL,
1479
+ description TEXT NOT NULL DEFAULT '',
1480
+ icon TEXT NOT NULL DEFAULT 'ti ti-folders',
1481
+ instructions TEXT NOT NULL DEFAULT '',
1482
+ default_model_profile_id TEXT,
1483
+ revision INTEGER NOT NULL DEFAULT 1,
1484
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1485
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1486
+ CONSTRAINT ai_projects_name_check CHECK (length(btrim(name)) BETWEEN 1 AND 120),
1487
+ CONSTRAINT ai_projects_description_check CHECK (length(description) <= 500),
1488
+ CONSTRAINT ai_projects_instructions_check CHECK (length(instructions) <= 16000)
1489
+ )
1490
+ `.simple();
1491
+
1492
+ // Projects were alpha-only while ownership was represented by a user FK.
1493
+ // Make the hard cut before normalizing any legacy rows: keep personal chats,
1494
+ // discard local alpha Projects, then build only the final access-owned schema.
1495
+ await sql`
1496
+ DO $$
1497
+ BEGIN
1498
+ IF EXISTS (
1499
+ SELECT 1 FROM information_schema.columns
1500
+ WHERE table_schema = 'ai' AND table_name = 'projects' AND column_name = 'owner_user_id'
1501
+ ) THEN
1502
+ IF to_regclass('ai.project_access') IS NOT NULL THEN
1503
+ DELETE FROM auth.access
1504
+ WHERE id IN (SELECT access_id FROM ai.project_access);
1505
+ END IF;
1506
+ DELETE FROM ai.projects;
1507
+ DROP INDEX IF EXISTS ai.idx_ai_projects_owner_name;
1508
+ ALTER TABLE ai.projects DROP COLUMN owner_user_id;
1509
+ END IF;
1510
+ END $$
1511
+ `.simple();
1512
+
1513
+ await sql`ALTER TABLE ai.projects ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
1514
+ await sql`
1515
+ DO $$
1516
+ BEGIN
1517
+ IF EXISTS (
1518
+ SELECT 1 FROM pg_constraint
1519
+ WHERE conrelid = 'ai.projects'::regclass AND conname = 'ai_projects_id_app_id_key'
1520
+ ) THEN
1521
+ ALTER TABLE ai.projects DROP CONSTRAINT ai_projects_id_app_id_key;
1522
+ END IF;
1523
+ IF EXISTS (
1524
+ SELECT 1 FROM information_schema.columns
1525
+ WHERE table_schema = 'ai' AND table_name = 'projects' AND column_name = 'app_id'
1526
+ ) THEN
1527
+ ALTER TABLE ai.projects DROP COLUMN app_id;
1528
+ END IF;
1529
+ END $$
1530
+ `.simple();
1531
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_projects_short_id ON ai.projects(short_id)`.simple();
1532
+ await backfillAiShortIds(
1533
+ "idx_ai_projects_short_id",
1534
+ await sql<{ id: string }[]>`SELECT id FROM ai.projects WHERE short_id IS NULL`,
1535
+ (id, shortId) => sql`UPDATE ai.projects SET short_id = ${shortId} WHERE id = ${id}::uuid`,
1536
+ );
1537
+ await sql`ALTER TABLE ai.projects ALTER COLUMN short_id SET NOT NULL`.simple();
1538
+ await sql`
1539
+ CREATE TABLE IF NOT EXISTS ai.project_access (
1540
+ project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
1541
+ access_id UUID NOT NULL REFERENCES auth.access(id) ON DELETE CASCADE,
1542
+ short_id TEXT NOT NULL,
1543
+ PRIMARY KEY (project_id, access_id)
1544
+ )
1545
+ `.simple();
1546
+ await sql`ALTER TABLE ai.project_access ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
1547
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_access_short_id ON ai.project_access(project_id, short_id)`.simple();
1548
+ for (const row of await sql<
1549
+ { project_id: string; access_id: string }[]
1550
+ >`SELECT project_id, access_id FROM ai.project_access WHERE short_id IS NULL`) {
1551
+ await withAiShortId(
1552
+ "idx_ai_project_access_short_id",
1553
+ (shortId) => sql`
1554
+ UPDATE ai.project_access SET short_id = ${shortId}
1555
+ WHERE project_id = ${row.project_id}::uuid AND access_id = ${row.access_id}::uuid
1556
+ `,
1557
+ );
1558
+ }
1559
+ await sql`ALTER TABLE ai.project_access ALTER COLUMN short_id SET NOT NULL`.simple();
1560
+
1561
+ await sql`
1562
+ CREATE TABLE IF NOT EXISTS ai.project_knowledge (
1563
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1564
+ short_id TEXT NOT NULL,
1565
+ project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
1566
+ title TEXT NOT NULL,
1567
+ content TEXT NOT NULL,
1568
+ search_document TSVECTOR GENERATED ALWAYS AS (
1569
+ setweight(to_tsvector('simple', title), 'A') || setweight(to_tsvector('simple', content), 'B')
1570
+ ) STORED,
1571
+ created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
1572
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1573
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1574
+ CONSTRAINT ai_project_knowledge_title_check CHECK (length(btrim(title)) BETWEEN 1 AND 200),
1575
+ CONSTRAINT ai_project_knowledge_content_check CHECK (length(btrim(content)) BETWEEN 1 AND 100000)
1576
+ )
1577
+ `.simple();
1578
+ await sql`ALTER TABLE ai.project_knowledge ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
1579
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_knowledge_short_id ON ai.project_knowledge(project_id, short_id)`.simple();
1580
+ await backfillAiShortIds(
1581
+ "idx_ai_project_knowledge_short_id",
1582
+ await sql<{ id: string }[]>`SELECT id FROM ai.project_knowledge WHERE short_id IS NULL`,
1583
+ (id, shortId) => sql`UPDATE ai.project_knowledge SET short_id = ${shortId} WHERE id = ${id}::uuid`,
1584
+ );
1585
+ await sql`ALTER TABLE ai.project_knowledge ALTER COLUMN short_id SET NOT NULL`.simple();
1586
+ await sql`CREATE INDEX IF NOT EXISTS idx_ai_project_knowledge_search ON ai.project_knowledge USING GIN(search_document)`.simple();
1587
+
1588
+ await sql`
1589
+ CREATE TABLE IF NOT EXISTS ai.project_files (
1590
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1591
+ short_id TEXT NOT NULL,
1592
+ project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
1593
+ path TEXT NOT NULL,
1594
+ media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
1595
+ bytes BYTEA NOT NULL,
1596
+ size INTEGER NOT NULL,
1597
+ created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
1598
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1599
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1600
+ UNIQUE (project_id, path),
1601
+ CONSTRAINT ai_project_files_size_check CHECK (size BETWEEN 0 AND 10485760)
1602
+ )
1603
+ `.simple();
1604
+ await sql`ALTER TABLE ai.project_files ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
1605
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_files_short_id ON ai.project_files(project_id, short_id)`.simple();
1606
+ await backfillAiShortIds(
1607
+ "idx_ai_project_files_short_id",
1608
+ await sql<{ id: string }[]>`SELECT id FROM ai.project_files WHERE short_id IS NULL`,
1609
+ (id, shortId) => sql`UPDATE ai.project_files SET short_id = ${shortId} WHERE id = ${id}::uuid`,
1610
+ );
1611
+ await sql`ALTER TABLE ai.project_files ALTER COLUMN short_id SET NOT NULL`.simple();
1612
+ await sql`ALTER TABLE ai.project_files ALTER COLUMN bytes SET STORAGE EXTERNAL`.simple();
1613
+
1614
+ await sql`DROP TABLE IF EXISTS ai.project_references CASCADE`.simple();
1615
+ await sql`
1616
+ CREATE TABLE IF NOT EXISTS ai.project_resource_refs (
1617
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1618
+ short_id TEXT NOT NULL,
1619
+ project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
1620
+ resource_type TEXT NOT NULL,
1621
+ resource_id TEXT NOT NULL,
1622
+ label TEXT NOT NULL DEFAULT '',
1623
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1624
+ UNIQUE (project_id, resource_type, resource_id)
1625
+ )
1626
+ `.simple();
1627
+ await sql`ALTER TABLE ai.project_resource_refs ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
1628
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_resource_refs_short_id ON ai.project_resource_refs(project_id, short_id)`.simple();
1629
+ await backfillAiShortIds(
1630
+ "idx_ai_project_resource_refs_short_id",
1631
+ await sql<{ id: string }[]>`SELECT id FROM ai.project_resource_refs WHERE short_id IS NULL`,
1632
+ (id, shortId) => sql`UPDATE ai.project_resource_refs SET short_id = ${shortId} WHERE id = ${id}::uuid`,
1633
+ );
1634
+ await sql`ALTER TABLE ai.project_resource_refs ALTER COLUMN short_id SET NOT NULL`.simple();
1635
+
1636
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES ai.projects(id) ON DELETE SET NULL`.simple();
1637
+ await sql`
1638
+ CREATE INDEX IF NOT EXISTS idx_ai_conversations_project_owner_updated
1639
+ ON ai.conversations(project_id, created_by_user_id, updated_at DESC)
1640
+ WHERE project_id IS NOT NULL AND archived_at IS NULL
1641
+ `.simple();
1642
+
1643
+ // Durable Realtime UI invalidations. Triggers live at the persistence seam so
1644
+ // route, worker, tool, enrichment, and scheduler writes cannot bypass them.
1645
+ // The outbox row commits or rolls back with the authoritative domain write;
1646
+ // publishing remains an after-commit runtime responsibility.
1647
+ await sql`
1648
+ CREATE TABLE IF NOT EXISTS ai.live_invalidation_outbox (
1649
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1650
+ change_id UUID NOT NULL,
1651
+ audience_user_id UUID NOT NULL,
1652
+ conversation_short_id TEXT,
1653
+ project_short_id TEXT,
1654
+ domains TEXT[] NOT NULL,
1655
+ attempts INTEGER NOT NULL DEFAULT 0,
1656
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1657
+ claimed_until TIMESTAMPTZ,
1658
+ delivered_at TIMESTAMPTZ,
1659
+ dead_at TIMESTAMPTZ,
1660
+ last_error TEXT,
1661
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1662
+ CONSTRAINT ai_live_invalidation_domains_check CHECK (
1663
+ cardinality(domains) > 0 AND domains <@ ARRAY[
1664
+ 'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
1665
+ 'project-list', 'project-detail', 'project-context'
1666
+ ]::text[]
1667
+ )
1668
+ )
1669
+ `.simple();
1670
+ await sql`
1671
+ DO $$
1672
+ BEGIN
1673
+ IF EXISTS (
1674
+ SELECT 1 FROM information_schema.columns
1675
+ WHERE table_schema = 'ai' AND table_name = 'live_invalidation_outbox' AND column_name = 'app_id'
1676
+ ) THEN
1677
+ DELETE FROM ai.live_invalidation_outbox;
1678
+ ALTER TABLE ai.live_invalidation_outbox DROP COLUMN app_id;
1679
+ END IF;
1680
+ END $$
1681
+ `.simple();
1682
+ // Audience IDs are immutable event-routing data. A foreign key makes an
1683
+ // unrelated user-delete cascade invoke Project triggers after the user row
1684
+ // has disappeared, aborting the authoritative delete instead of merely
1685
+ // leaving a harmless undeliverable invalidation.
1686
+ await sql`
1687
+ ALTER TABLE ai.live_invalidation_outbox
1688
+ DROP CONSTRAINT IF EXISTS live_invalidation_outbox_audience_user_id_fkey
1689
+ `.simple();
1690
+ await sql`
1691
+ CREATE INDEX IF NOT EXISTS idx_ai_live_invalidation_outbox_pending
1692
+ ON ai.live_invalidation_outbox(next_attempt_at, created_at, id)
1693
+ WHERE delivered_at IS NULL AND dead_at IS NULL
1694
+ `.simple();
1695
+ await sql`
1696
+ CREATE INDEX IF NOT EXISTS idx_ai_live_invalidation_outbox_user_order
1697
+ ON ai.live_invalidation_outbox(audience_user_id, created_at, id)
1698
+ WHERE delivered_at IS NULL AND dead_at IS NULL
1699
+ `.simple();
1700
+
1701
+ // Hard cut: the former app-scoped signature is a distinct PostgreSQL
1702
+ // overload, so CREATE OR REPLACE cannot remove it for us.
1703
+ await sql`
1704
+ DROP FUNCTION IF EXISTS ai.enqueue_live_for_user(UUID, TEXT, UUID, TEXT, TEXT, TEXT[])
1705
+ `.simple();
1706
+
1707
+ await sql`
1708
+ CREATE OR REPLACE FUNCTION ai.enqueue_live_for_user(
1709
+ p_change_id UUID,
1710
+ p_user_id UUID,
1711
+ p_conversation_short_id TEXT,
1712
+ p_project_short_id TEXT,
1713
+ p_domains TEXT[]
1714
+ ) RETURNS void AS $$
1715
+ BEGIN
1716
+ IF p_user_id IS NULL OR cardinality(p_domains) = 0 THEN RETURN; END IF;
1717
+ INSERT INTO ai.live_invalidation_outbox (
1718
+ change_id, audience_user_id, conversation_short_id, project_short_id, domains
1719
+ ) VALUES (
1720
+ p_change_id, p_user_id, p_conversation_short_id, p_project_short_id,
1721
+ ARRAY(SELECT DISTINCT domain FROM unnest(p_domains) domain ORDER BY domain)
1722
+ );
1723
+ END
1724
+ $$ LANGUAGE plpgsql
1725
+ `.simple();
1726
+
1727
+ await sql`
1728
+ CREATE OR REPLACE FUNCTION ai.enqueue_live_for_conversation(
1729
+ p_conversation_id UUID,
1730
+ p_domains TEXT[]
1731
+ ) RETURNS void AS $$
1732
+ DECLARE
1733
+ target RECORD;
1734
+ BEGIN
1735
+ SELECT conversation.created_by_user_id, conversation.short_id,
1736
+ project.short_id AS project_short_id
1737
+ INTO target
1738
+ FROM ai.conversations conversation
1739
+ LEFT JOIN ai.projects project ON project.id = conversation.project_id
1740
+ WHERE conversation.id = p_conversation_id;
1741
+ IF NOT FOUND THEN RETURN; END IF;
1742
+ PERFORM ai.enqueue_live_for_user(
1743
+ gen_random_uuid(), target.created_by_user_id, target.short_id, target.project_short_id, p_domains
1744
+ );
1745
+ END
1746
+ $$ LANGUAGE plpgsql
1747
+ `.simple();
1748
+
1749
+ await sql`
1750
+ CREATE OR REPLACE FUNCTION ai.enqueue_live_for_project(
1751
+ p_project_id UUID,
1752
+ p_domains TEXT[]
1753
+ ) RETURNS void AS $$
1754
+ DECLARE
1755
+ change UUID := gen_random_uuid();
1756
+ project_short TEXT;
1757
+ recipient RECORD;
1758
+ BEGIN
1759
+ SELECT short_id INTO project_short FROM ai.projects WHERE id = p_project_id;
1760
+ IF NOT FOUND THEN RETURN; END IF;
1761
+
1762
+ FOR recipient IN
1763
+ WITH RECURSIVE
1764
+ access_rows AS (
1765
+ SELECT access.*
1766
+ FROM ai.project_access project_access
1767
+ JOIN auth.access access ON access.id = project_access.access_id
1768
+ WHERE project_access.project_id = p_project_id
1769
+ AND access.permission <> 'none'
1770
+ ),
1771
+ root_groups(group_id, group_ids) AS (
1772
+ SELECT group_id, ARRAY[group_id]::uuid[]
1773
+ FROM access_rows
1774
+ WHERE group_id IS NOT NULL
1775
+ UNION ALL
1776
+ SELECT nested.child_group_id, root.group_ids || nested.child_group_id
1777
+ FROM auth.group_groups_v2 nested
1778
+ JOIN root_groups root ON root.group_id = nested.parent_group_id
1779
+ WHERE NOT nested.child_group_id = ANY(root.group_ids)
1780
+ ),
1781
+ recipients(user_id) AS (
1782
+ SELECT user_id FROM access_rows WHERE user_id IS NOT NULL
1783
+ UNION
1784
+ SELECT membership.user_id
1785
+ FROM root_groups root
1786
+ JOIN auth.user_groups_v2 membership ON membership.group_id = root.group_id
1787
+ UNION
1788
+ SELECT users.id
1789
+ FROM auth.users users
1790
+ WHERE EXISTS (
1791
+ SELECT 1 FROM access_rows
1792
+ WHERE authenticated_only OR (
1793
+ user_id IS NULL AND group_id IS NULL AND service_account_id IS NULL AND NOT authenticated_only
1794
+ )
1795
+ )
1796
+ )
1797
+ SELECT DISTINCT user_id FROM recipients WHERE user_id IS NOT NULL
1798
+ LOOP
1799
+ PERFORM ai.enqueue_live_for_user(change, recipient.user_id, NULL, project_short, p_domains);
1800
+ END LOOP;
1801
+ END
1802
+ $$ LANGUAGE plpgsql
1803
+ `.simple();
1804
+
1805
+ await sql`
1806
+ CREATE OR REPLACE FUNCTION ai.live_conversation_row_changed() RETURNS trigger AS $$
1807
+ DECLARE
1808
+ row_data RECORD;
1809
+ project_short TEXT;
1810
+ BEGIN
1811
+ IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
1812
+ SELECT short_id INTO project_short FROM ai.projects WHERE id = row_data.project_id;
1813
+ PERFORM ai.enqueue_live_for_user(
1814
+ gen_random_uuid(), row_data.created_by_user_id, row_data.short_id, project_short,
1815
+ ARRAY['conversation-list', 'conversation-detail']::text[]
1816
+ );
1817
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1818
+ END
1819
+ $$ LANGUAGE plpgsql
1820
+ `.simple();
1821
+ await sql`DROP TRIGGER IF EXISTS ai_live_conversations_changed ON ai.conversations`.simple();
1822
+ await sql`
1823
+ CREATE TRIGGER ai_live_conversations_changed
1824
+ AFTER INSERT OR UPDATE OR DELETE ON ai.conversations
1825
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_row_changed()
1826
+ `.simple();
1827
+
1828
+ await sql`
1829
+ CREATE OR REPLACE FUNCTION ai.live_conversation_child_changed() RETURNS trigger AS $$
1830
+ DECLARE
1831
+ row_data RECORD;
1832
+ conversation UUID;
1833
+ BEGIN
1834
+ IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
1835
+ IF TG_TABLE_NAME = 'chat_task_occurrences' THEN
1836
+ SELECT conversation_id INTO conversation FROM ai.chat_tasks WHERE id = row_data.task_id;
1837
+ ELSE
1838
+ conversation := row_data.conversation_id;
1839
+ END IF;
1840
+ IF conversation IS NOT NULL THEN
1841
+ PERFORM ai.enqueue_live_for_conversation(conversation, string_to_array(TG_ARGV[0], ','));
1842
+ END IF;
1843
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1844
+ END
1845
+ $$ LANGUAGE plpgsql
1846
+ `.simple();
1847
+ await sql`DROP TRIGGER IF EXISTS ai_live_conversation_sources_changed ON ai.conversation_sources`.simple();
1848
+ await sql`
1849
+ CREATE TRIGGER ai_live_conversation_sources_changed AFTER INSERT OR UPDATE OR DELETE ON ai.conversation_sources
1850
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-sources')
1851
+ `.simple();
1852
+ await sql`DROP TRIGGER IF EXISTS ai_live_conversation_resources_changed ON ai.conversation_resource_refs`.simple();
1853
+ await sql`
1854
+ CREATE TRIGGER ai_live_conversation_resources_changed AFTER INSERT OR UPDATE OR DELETE ON ai.conversation_resource_refs
1855
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-sources')
1856
+ `.simple();
1857
+ await sql`DROP TRIGGER IF EXISTS ai_live_conversation_files_changed ON ai.files`.simple();
1858
+ await sql`
1859
+ CREATE TRIGGER ai_live_conversation_files_changed AFTER INSERT OR UPDATE OR DELETE ON ai.files
1860
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-files,conversation-sources')
1861
+ `.simple();
1862
+ await sql`DROP TRIGGER IF EXISTS ai_live_turns_changed ON ai.turns`.simple();
1863
+ await sql`DROP TRIGGER IF EXISTS ai_live_turns_status_changed ON ai.turns`.simple();
1864
+ await sql`
1865
+ CREATE TRIGGER ai_live_turns_changed
1866
+ AFTER INSERT OR DELETE ON ai.turns
1867
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
1868
+ `.simple();
1869
+ await sql`
1870
+ CREATE TRIGGER ai_live_turns_status_changed
1871
+ AFTER UPDATE OF status, error, completed_at, cancel_requested_at ON ai.turns
1872
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
1873
+ `.simple();
1874
+ await sql`DROP TRIGGER IF EXISTS ai_live_chat_tasks_changed ON ai.chat_tasks`.simple();
1875
+ await sql`
1876
+ CREATE TRIGGER ai_live_chat_tasks_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_tasks
1877
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-tasks')
1878
+ `.simple();
1879
+ await sql`DROP TRIGGER IF EXISTS ai_live_chat_task_occurrences_changed ON ai.chat_task_occurrences`.simple();
1880
+ await sql`
1881
+ CREATE TRIGGER ai_live_chat_task_occurrences_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_task_occurrences
1882
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-tasks')
1883
+ `.simple();
1884
+
1885
+ await sql`
1886
+ CREATE OR REPLACE FUNCTION ai.live_project_row_changed() RETURNS trigger AS $$
1887
+ DECLARE row_data RECORD;
1888
+ BEGIN
1889
+ IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
1890
+ PERFORM ai.enqueue_live_for_project(
1891
+ row_data.id,
1892
+ ARRAY['project-list', 'project-detail', 'project-context', 'conversation-list']::text[]
1893
+ );
1894
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1895
+ END
1896
+ $$ LANGUAGE plpgsql
1897
+ `.simple();
1898
+ await sql`DROP TRIGGER IF EXISTS ai_live_projects_changed ON ai.projects`.simple();
1899
+ await sql`DROP TRIGGER IF EXISTS ai_live_projects_deleted ON ai.projects`.simple();
1900
+ await sql`
1901
+ CREATE TRIGGER ai_live_projects_changed
1902
+ AFTER INSERT OR UPDATE ON ai.projects
1903
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_row_changed()
1904
+ `.simple();
1905
+ await sql`
1906
+ CREATE TRIGGER ai_live_projects_deleted
1907
+ BEFORE DELETE ON ai.projects
1908
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_row_changed()
1909
+ `.simple();
1910
+
1911
+ await sql`
1912
+ CREATE OR REPLACE FUNCTION ai.live_project_child_changed() RETURNS trigger AS $$
1913
+ DECLARE row_data RECORD;
1914
+ BEGIN
1915
+ IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
1916
+ PERFORM ai.enqueue_live_for_project(row_data.project_id, string_to_array(TG_ARGV[0], ','));
1917
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1918
+ END
1919
+ $$ LANGUAGE plpgsql
1920
+ `.simple();
1921
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_knowledge_changed ON ai.project_knowledge`.simple();
1922
+ await sql`
1923
+ CREATE TRIGGER ai_live_project_knowledge_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_knowledge
1924
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
1925
+ `.simple();
1926
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_files_changed ON ai.project_files`.simple();
1927
+ await sql`
1928
+ CREATE TRIGGER ai_live_project_files_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_files
1929
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
1930
+ `.simple();
1931
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_resource_refs_changed ON ai.project_resource_refs`.simple();
1932
+ await sql`
1933
+ CREATE TRIGGER ai_live_project_resource_refs_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_resource_refs
1934
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
1935
+ `.simple();
1936
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_access_added ON ai.project_access`.simple();
1937
+ await sql`
1938
+ CREATE TRIGGER ai_live_project_access_added AFTER INSERT OR UPDATE ON ai.project_access
1939
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-list,project-detail,project-context,conversation-list')
1940
+ `.simple();
1941
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_access_removed ON ai.project_access`.simple();
1942
+ await sql`
1943
+ CREATE TRIGGER ai_live_project_access_removed BEFORE DELETE ON ai.project_access
1944
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-list,project-detail,project-context,conversation-list')
1945
+ `.simple();
1946
+ await sql`
1947
+ CREATE OR REPLACE FUNCTION ai.live_project_access_permission_changed() RETURNS trigger AS $$
1948
+ DECLARE
1949
+ project UUID;
1950
+ changed_access UUID;
1951
+ BEGIN
1952
+ changed_access := CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END;
1953
+ FOR project IN SELECT project_id FROM ai.project_access WHERE access_id = changed_access
1954
+ LOOP
1955
+ PERFORM ai.enqueue_live_for_project(
1956
+ project,
1957
+ ARRAY['project-list', 'project-detail', 'project-context', 'conversation-list']::text[]
1958
+ );
1959
+ END LOOP;
1960
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
1961
+ END
1962
+ $$ LANGUAGE plpgsql
1963
+ `.simple();
1964
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_access_permission_changed ON auth.access`.simple();
1965
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_access_permission_changing ON auth.access`.simple();
1966
+ await sql`
1967
+ CREATE TRIGGER ai_live_project_access_permission_changing
1968
+ BEFORE UPDATE OF permission ON auth.access
1969
+ FOR EACH ROW
1970
+ WHEN (OLD.permission IS DISTINCT FROM NEW.permission)
1971
+ EXECUTE FUNCTION ai.live_project_access_permission_changed()
1972
+ `.simple();
1973
+ await sql`
1974
+ CREATE TRIGGER ai_live_project_access_permission_changed
1975
+ AFTER UPDATE OF permission ON auth.access
1976
+ FOR EACH ROW
1977
+ WHEN (OLD.permission IS DISTINCT FROM NEW.permission)
1978
+ EXECUTE FUNCTION ai.live_project_access_permission_changed()
1979
+ `.simple();
1980
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_access_deleting ON auth.access`.simple();
1981
+ await sql`
1982
+ CREATE TRIGGER ai_live_project_access_deleting
1983
+ BEFORE DELETE ON auth.access
1984
+ FOR EACH ROW
1985
+ EXECUTE FUNCTION ai.live_project_access_permission_changed()
1986
+ `.simple();
1987
+
1988
+ await sql`
1989
+ CREATE OR REPLACE FUNCTION ai.live_project_group_membership_changed() RETURNS trigger AS $$
1990
+ DECLARE
1991
+ changed_group UUID;
1992
+ project UUID;
1993
+ BEGIN
1994
+ IF TG_TABLE_NAME = 'user_groups_v2' THEN
1995
+ changed_group := CASE WHEN TG_OP = 'DELETE' THEN OLD.group_id ELSE NEW.group_id END;
1996
+ ELSE
1997
+ changed_group := CASE WHEN TG_OP = 'DELETE' THEN OLD.parent_group_id ELSE NEW.parent_group_id END;
1998
+ END IF;
1999
+ FOR project IN
2000
+ WITH RECURSIVE ancestors(group_id, seen) AS (
2001
+ SELECT changed_group, ARRAY[changed_group]::uuid[]
2002
+ UNION ALL
2003
+ SELECT relation.parent_group_id, ancestors.seen || relation.parent_group_id
2004
+ FROM auth.group_groups_v2 relation
2005
+ JOIN ancestors ON relation.child_group_id = ancestors.group_id
2006
+ WHERE NOT relation.parent_group_id = ANY(ancestors.seen)
2007
+ )
2008
+ SELECT DISTINCT project_access.project_id
2009
+ FROM ancestors
2010
+ JOIN auth.access access ON access.group_id = ancestors.group_id AND access.permission <> 'none'
2011
+ JOIN ai.project_access project_access ON project_access.access_id = access.id
2012
+ LOOP
2013
+ PERFORM ai.enqueue_live_for_project(
2014
+ project,
2015
+ ARRAY['project-list', 'project-detail', 'project-context', 'conversation-list']::text[]
2016
+ );
2017
+ END LOOP;
2018
+ RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
2019
+ END
2020
+ $$ LANGUAGE plpgsql
2021
+ `.simple();
2022
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_user_group_added ON auth.user_groups_v2`.simple();
2023
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_user_group_removed ON auth.user_groups_v2`.simple();
2024
+ await sql`
2025
+ CREATE TRIGGER ai_live_project_user_group_added AFTER INSERT ON auth.user_groups_v2
2026
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
2027
+ `.simple();
2028
+ await sql`
2029
+ CREATE TRIGGER ai_live_project_user_group_removed BEFORE DELETE ON auth.user_groups_v2
2030
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
2031
+ `.simple();
2032
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_group_group_added ON auth.group_groups_v2`.simple();
2033
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_group_group_removed ON auth.group_groups_v2`.simple();
2034
+ await sql`
2035
+ CREATE TRIGGER ai_live_project_group_group_added AFTER INSERT ON auth.group_groups_v2
2036
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
2037
+ `.simple();
2038
+ await sql`
2039
+ CREATE TRIGGER ai_live_project_group_group_removed BEFORE DELETE ON auth.group_groups_v2
2040
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
2041
+ `.simple();
2042
+
2043
+ // Provider API keys used to live inside the ai.model_profiles_json setting.
2044
+ // A JSON setting is delivered to the admin UI in full, so every key rode
2045
+ // along in the page payload; a per-profile row keeps them server-side and
2046
+ // lets a deployment hold as many as it has profiles.
2047
+ await sql`
2048
+ CREATE TABLE IF NOT EXISTS ai.model_credentials (
2049
+ profile_id TEXT PRIMARY KEY,
2050
+ secret TEXT NOT NULL,
2051
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2052
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
2053
+ )
2054
+ `.simple();
2055
+
2056
+ // One-time move of keys that were stored inside the profiles setting. Once
2057
+ // the blob holds no apiKey the split yields nothing and this is a no-op, so
2058
+ // it is safe to re-run. Dynamic imports keep the module free of store deps.
2059
+ {
2060
+ const { coreSettings } = await import("../services");
2061
+ const { setAiCredential, splitAiProfileCredentials } = await import("./credentials");
2062
+ const split = splitAiProfileCredentials((await coreSettings.get<string>("ai.model_profiles_json")) ?? "[]");
2063
+ if (split && split.credentials.length > 0) {
2064
+ for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret);
2065
+ await coreSettings.set("ai.model_profiles_json", split.profilesJson);
2066
+ console.log(` ✓ moved ${split.credentials.length} AI provider key(s) out of ai.model_profiles_json`);
2067
+ }
2068
+ }
2069
+
2070
+ await sql`ALTER TABLE ai.structured_runs
2071
+ ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
2072
+ ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
2073
+ ADD COLUMN IF NOT EXISTS turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
2074
+ ADD COLUMN IF NOT EXISTS workflow_run_id UUID,
2075
+ ADD COLUMN IF NOT EXISTS trace_id TEXT`.simple();
2076
+ await sql`CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_user_created ON ai.structured_runs(user_id, created_at DESC)`.simple();
2077
+
2078
+ await migrateAiTurnUsage();
2079
+ await migrateAiModelAccess();
2080
+
2081
+ console.log(" ✓ ai conversation tables");
2082
+ };