@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { CODE_SOURCE_TOOLS, type CodeSourceToolName } from "./code-source-contracts";
2
+ export { CodeResourceId, CODE_RUNTIME_TOOL_NAMES, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
1
3
  export { assistantAiSettingsState, listAssistantAiModels } from "./assistant-models";
2
4
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
3
5
  export {
@@ -19,6 +21,7 @@ export {
19
21
  type AiChatTaskView,
20
22
  AiConversationIdSchema,
21
23
  ChatTaskIdSchema,
24
+ ChatTaskGrantsSchema,
22
25
  ChatTaskOccurrenceIdSchema,
23
26
  ChatTaskScheduleInputSchema,
24
27
  chatTaskCreateFingerprint,
@@ -72,6 +75,9 @@ export {
72
75
  type AiFileStat,
73
76
  guessAiMediaType,
74
77
  listAiConversationFiles,
78
+ readAiConversationFile,
79
+ createAiConversationArtifact,
80
+ writeAiConversationFile,
75
81
  normalizeAiFilePath,
76
82
  } from "./files-store";
77
83
  export {
@@ -214,7 +220,7 @@ export {
214
220
  selectAiModelProfile,
215
221
  toPublicAiSettingsState,
216
222
  } from "./settings";
217
- export { AI_SHORT_ID_PATTERN, createAiShortId } from "./short-id";
223
+ export { AI_SHORT_ID_PATTERN, createAiShortId, withAiShortIdForDb } from "./short-id";
218
224
  export {
219
225
  AI_SKILL_DESCRIPTION_MAX_CHARS,
220
226
  AI_SKILL_EXTRA_FRONTMATTER_MAX_CHARS,
@@ -237,7 +243,7 @@ export {
237
243
  validateAiSkillName,
238
244
  validateAiSkillReferences,
239
245
  } from "./skill-format";
240
- export { seedCloudAiSkills } from "./skill-seeds";
246
+ export { seedCloudAiSkills } from "./skills";
241
247
  export {
242
248
  type AiLoadedSkillSnapshot,
243
249
  type AiSkill,
@@ -335,3 +341,21 @@ export {
335
341
  type CloudAiViewImageOutput,
336
342
  CloudAiViewImageOutputSchema,
337
343
  } from "./vision-tool";
344
+
345
+ export {
346
+ createAiTranscriptionProvider,
347
+ resolveAiAudioModel,
348
+ runAiTranscription,
349
+ type AiResolvedAudioModel,
350
+ type RunAiTranscriptionInput,
351
+ } from "./transcription";
352
+
353
+ export { createCloudAiTranscribeAudioTool, CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema } from "./audio-tool";
354
+
355
+ export { getAiChatQuotas } from "./chat-quotas";
356
+
357
+ export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
358
+ export { aiFileContentVersion, AiFileWriteError, AiFileVersionConflict } from "./file-content-version";
359
+
360
+ // Framework-owned managed execution host transport.
361
+ export { createCodeCapabilityTransport, CODE_CAPABILITY_TOKEN_HEADER } from "./code-capability-transport";
@@ -0,0 +1,198 @@
1
+ import { hasBillableAiPricing } from "../shared/ai-costs";
2
+ import { sql, type SQL } from "bun";
3
+ import type { AccessSubject } from "../server/services/access";
4
+ import { aiCostDecimal, aiCostUnits, aiDecimalUnits, aiBudgetUnits } from "../shared/ai-costs";
5
+ import type { AiModelProfile } from "./types";
6
+ import { aiQuotas, AiQuotaError, quotaWindow } from "./quotas";
7
+
8
+ export class AiBackgroundCostError extends Error {
9
+ readonly code = "ai_background_cost_stop";
10
+ constructor() {
11
+ super("Background AI is paused by the cost safety limit. An administrator must review Usage rules.");
12
+ }
13
+ }
14
+
15
+ /** Admission can fail without latching the instance-wide emergency stop. */
16
+ export class AiBackgroundAdmissionError extends Error {
17
+ readonly code: string;
18
+ constructor(readonly retryable: boolean) {
19
+ super(
20
+ retryable
21
+ ? "Background AI budget is reserved by running calls. Try again after they finish."
22
+ : "This call cannot fit in the remaining background AI budget.",
23
+ );
24
+ this.code = retryable ? "ai_background_budget_reserved" : "ai_background_budget_insufficient";
25
+ }
26
+ }
27
+
28
+ export type AiCallContext = {
29
+ kind: "chat" | "background";
30
+ task: string;
31
+ subject?: AccessSubject | null;
32
+ userId?: string;
33
+ conversationId?: string;
34
+ turnId?: string;
35
+ workflowRunId?: string;
36
+ stepKey?: string;
37
+ traceId?: string;
38
+ appId?: string;
39
+ };
40
+
41
+ export async function backgroundCostState(db: SQL = sql) {
42
+ const [row] = await db<
43
+ { used: number; reserved: number; usedExact: string; reservedExact: string; unknown: number; stoppedAt: Date | null; unit: string }[]
44
+ >`
45
+ SELECT COALESCE(sum(cost),0)::text AS "usedExact",
46
+ COALESCE(sum(reserved) FILTER(WHERE finished_at IS NULL AND lease_expires_at>now()),0)::text AS "reservedExact",
47
+ COALESCE(sum(cost),0)::float8 AS used,
48
+ COALESCE(sum(reserved) FILTER(WHERE finished_at IS NULL AND lease_expires_at>now()),0)::float8 AS reserved,
49
+ count(*) FILTER(WHERE (COALESCE((pricing->>'inputPerMillion')::numeric,0)>0 OR COALESCE((pricing->>'outputPerMillion')::numeric,0)>0) AND cost IS NULL AND (finished_at IS NOT NULL OR lease_expires_at<=now()))::int AS unknown,
50
+ (SELECT background_stopped_at FROM ai.cost_config WHERE singleton) AS "stoppedAt",
51
+ (SELECT unit FROM ai.cost_config WHERE singleton) AS unit
52
+ FROM ai.inference_calls WHERE kind='background' AND started_at>=now()-interval '24 hours'`;
53
+ return row!;
54
+ }
55
+
56
+ /** Called under the configuration lock; persist transitions before notification delivery. */
57
+ async function checkBackgroundBudget(db: SQL) {
58
+ const config = await aiQuotas.config(db);
59
+ const state = await backgroundCostState(db);
60
+ if (!config.background?.enabled) return state;
61
+ const [flags] = await db<{ background_warned: boolean }[]>`SELECT background_warned FROM ai.cost_config WHERE singleton`;
62
+ const warning = config.background.warnAt !== null && aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.warnAt);
63
+ const stop = aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.stopAt) || state.unknown > 0;
64
+ for (const kind of [...(warning && !flags!.background_warned ? ["warning"] : []), ...(stop && !state.stoppedAt ? ["stop"] : [])])
65
+ await db`INSERT INTO ai.cost_alerts(id,kind,cost,unit) VALUES(${crypto.randomUUID()}::uuid,${kind},${state.usedExact}::numeric,${config.unit ?? "EUR"})`;
66
+ await db`UPDATE ai.cost_config SET background_warned=${warning},
67
+ background_stopped_at=CASE WHEN ${stop} THEN COALESCE(background_stopped_at,clock_timestamp()) ELSE background_stopped_at END WHERE singleton`;
68
+ return backgroundCostState(db);
69
+ }
70
+
71
+ /** Admission and reservation share the settings lock across all worker processes. */
72
+ export async function beginAiCall(
73
+ profile: AiModelProfile,
74
+ context: AiCallContext,
75
+ inputTokens: number,
76
+ requestedOutput?: number,
77
+ outputBound?: number,
78
+ ) {
79
+ const result = await sql.begin(async (db) => {
80
+ await db`SELECT singleton FROM ai.cost_config WHERE singleton FOR UPDATE`;
81
+ const config = await aiQuotas.config(db);
82
+ let remaining: bigint | undefined;
83
+ let backgroundHeadroom: bigint | undefined;
84
+ if (hasBillableAiPricing(profile.pricing) && context.kind === "background" && config.background?.enabled) {
85
+ const state = await checkBackgroundBudget(db);
86
+ if (state.stoppedAt || aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.stopAt) || state.unknown) {
87
+ await db`UPDATE ai.cost_config SET background_stopped_at=COALESCE(background_stopped_at,clock_timestamp()) WHERE singleton`;
88
+ return { error: new AiBackgroundCostError() };
89
+ }
90
+ backgroundHeadroom = aiBudgetUnits(config.background.stopAt) - aiDecimalUnits(state.usedExact);
91
+ remaining = backgroundHeadroom - aiDecimalUnits(state.reservedExact);
92
+ }
93
+ if (hasBillableAiPricing(profile.pricing) && context.kind === "chat" && context.subject && config.enabled) {
94
+ const now = new Date();
95
+ const snapshot = await aiQuotas.snapshot(context.subject, profile.id, now, db);
96
+ for (const balance of snapshot.balances) {
97
+ if (balance.bypassed || balance.limit === null) continue;
98
+ if (balance.unknown)
99
+ return {
100
+ error: new AiQuotaError(
101
+ "quota_usage_unknown",
102
+ "Chat costs could not be measured. Reset the allowance or wait for the next window.",
103
+ ),
104
+ };
105
+ const rule = config.rules.find((rule) => rule.scope === balance.scope)!;
106
+ const window = quotaWindow(rule.anchor, rule.hours, now);
107
+ const user = context.subject.type === "user" ? context.subject.userId : null;
108
+ const service = context.subject.type === "service_account" ? context.subject.serviceAccountId : null;
109
+ const [pending] = await db<{ committed: string }[]>`
110
+ SELECT (COALESCE(sum(cost),0)+COALESCE(sum(reserved) FILTER(WHERE finished_at IS NULL AND lease_expires_at>now()),0))::text AS committed FROM ai.inference_calls c
111
+ WHERE kind='chat'
112
+ AND user_id IS NOT DISTINCT FROM ${user}::uuid AND service_account_id IS NOT DISTINCT FROM ${service}::uuid
113
+ AND (${balance.scope}='*' OR model_profile_id=${balance.scope}) AND started_at>=${window.from} AND started_at<${window.until}
114
+ AND started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets r
115
+ WHERE r.user_id IS NOT DISTINCT FROM ${user}::uuid AND r.service_account_id IS NOT DISTINCT FROM ${service}::uuid AND r.scope=${balance.scope}),'-infinity'::timestamptz)`;
116
+ const available = aiBudgetUnits(balance.limit) - aiDecimalUnits(pending?.committed ?? "0");
117
+ remaining = remaining === undefined || available < remaining ? available : remaining;
118
+ }
119
+ }
120
+ let maxOutputTokens = requestedOutput;
121
+ let reserved = "0";
122
+ if (profile.pricing && remaining !== undefined) {
123
+ const inputCost = aiCostUnits(profile.pricing, inputTokens, 0);
124
+ const outputPrice = aiCostUnits(profile.pricing, 0, 1);
125
+ const affordable = outputPrice > 0n ? Number((remaining - inputCost) / outputPrice) : Infinity;
126
+ if (inputCost > remaining || affordable < 1)
127
+ return {
128
+ error:
129
+ context.kind === "background"
130
+ ? new AiBackgroundAdmissionError(backgroundHeadroom !== undefined && inputCost + outputPrice <= backgroundHeadroom)
131
+ : new AiQuotaError("quota_exhausted", "Chat cost limit reached."),
132
+ };
133
+ const reserveOutput = Math.min(requestedOutput ?? outputBound ?? affordable, affordable);
134
+ // Preserve adapter defaults unless an explicit maximum or budget requires a cap.
135
+ if (requestedOutput !== undefined || reserveOutput < (outputBound ?? Infinity)) maxOutputTokens = reserveOutput;
136
+ reserved = aiCostDecimal(aiCostUnits(profile.pricing, inputTokens, Number.isFinite(reserveOutput) ? reserveOutput : 0));
137
+ }
138
+ const workflow = context.workflowRunId
139
+ ? (
140
+ await db<{ id: string; name: string; app: string; revision: number }[]>`
141
+ SELECT w.id,w.name,r.app_id AS app,v.revision FROM workflows.run r
142
+ JOIN workflows.workflow w ON w.id=r.workflow_id JOIN workflows.version v ON v.id=r.workflow_version_id
143
+ WHERE r.id=${context.workflowRunId}::uuid`
144
+ )[0]
145
+ : undefined;
146
+ const id = crypto.randomUUID();
147
+ const userId = context.subject?.type === "user" ? context.subject.userId : context.userId;
148
+ const serviceId = context.subject?.type === "service_account" ? context.subject.serviceAccountId : null;
149
+ await db`INSERT INTO ai.inference_calls(id,user_id,service_account_id,model_profile_id,provider_model,kind,task,
150
+ turn_id,turn_attempt,conversation_id,workflow_run_id,step_key,trace_id,app_id,workflow_id,workflow_name,workflow_version,pricing,reserved,lease_expires_at)
151
+ VALUES(${id}::uuid,CASE WHEN ${serviceId}::uuid IS NULL THEN COALESCE(${userId ?? null}::uuid,(SELECT created_by_user_id FROM ai.conversations WHERE id=${context.conversationId ?? null}::uuid)) ELSE NULL END,
152
+ ${serviceId}::uuid,${profile.id},${profile.model},${context.kind},${context.task},${context.turnId ?? null}::uuid,
153
+ (SELECT attempt FROM ai.turns WHERE id=${context.turnId ?? null}::uuid),${context.conversationId ?? null}::uuid,
154
+ ${context.workflowRunId ?? null}::uuid,${context.stepKey ?? null},${context.traceId ?? null},COALESCE(${workflow?.app ?? context.appId ?? null},(SELECT launched_by_app_id FROM ai.conversations WHERE id=${context.conversationId ?? null}::uuid),'core'),${workflow?.id ?? null}::uuid,${workflow?.name ?? null},${workflow?.revision ?? null},
155
+ (${profile.pricing ? JSON.stringify(profile.pricing) : null}::text)::jsonb,${reserved}::numeric,now()+interval '2 minutes')`;
156
+ return { id, maxOutputTokens };
157
+ });
158
+ if (result.error) throw result.error;
159
+ return { id: result.id!, maxOutputTokens: result.maxOutputTokens };
160
+ }
161
+
162
+ export async function finishAiCall(
163
+ id: string,
164
+ usage: { input: number; output: number; estimated?: boolean } | undefined,
165
+ status: "ok" | "failed",
166
+ ) {
167
+ await sql.begin(async (db) => {
168
+ await db`SELECT singleton FROM ai.cost_config WHERE singleton FOR UPDATE`;
169
+ const [call] = await db<
170
+ { pricing: AiModelProfile["pricing"]; kind: string }[]
171
+ >`SELECT pricing,kind FROM ai.inference_calls WHERE id=${id}::uuid AND finished_at IS NULL FOR UPDATE`;
172
+ if (!call) return;
173
+ const cost =
174
+ call.pricing?.inputPerMillion === 0 && call.pricing.outputPerMillion === 0
175
+ ? "0"
176
+ : call.pricing && usage
177
+ ? aiCostDecimal(aiCostUnits(call.pricing, usage.input, usage.output))
178
+ : null;
179
+ await db`UPDATE ai.inference_calls SET input=${usage?.input ?? null},output=${usage?.output ?? null},estimated=${usage?.estimated ?? false},
180
+ cost=${cost}::numeric,reserved=0,finished_at=clock_timestamp(),status=${status},error_code=${status === "failed" ? "ai_provider_call_failed" : null} WHERE id=${id}::uuid`;
181
+ if (call.kind === "background") await checkBackgroundBudget(db);
182
+ });
183
+ }
184
+
185
+ export async function releaseBackgroundCostStop(actorId: string) {
186
+ return sql.begin(async (db) => {
187
+ await db`SELECT singleton FROM ai.cost_config WHERE singleton FOR UPDATE`;
188
+ const config = await aiQuotas.config(db);
189
+ const state = await backgroundCostState(db);
190
+ if (config.background?.enabled && (aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.stopAt) || state.unknown))
191
+ throw new AiBackgroundCostError();
192
+ if (!state.stoppedAt) return state;
193
+ await db`UPDATE ai.cost_config SET background_stopped_at=NULL,background_warned=false,revision=revision+1 WHERE singleton`;
194
+ const next = await aiQuotas.config(db);
195
+ await db`INSERT INTO ai.cost_changes(revision,actor_id,config) VALUES(${next.revision},${actorId}::uuid,(${JSON.stringify({ ...next, action: "release-background-stop" })}::text)::jsonb)`;
196
+ return backgroundCostState(db);
197
+ });
198
+ }
@@ -7,6 +7,7 @@ export const AI_INVALIDATION_DOMAINS = [
7
7
  "conversation-sources",
8
8
  "conversation-files",
9
9
  "conversation-tasks",
10
+ "conversation-dictations",
10
11
  "project-list",
11
12
  "project-detail",
12
13
  "project-context",
@@ -129,7 +130,10 @@ const AiConversationSchema = z
129
130
  keywords: z.array(z.string()),
130
131
  pinnedAt: z.string().nullable(),
131
132
  archivedAt: z.string().nullable(),
132
- runStatus: z.enum(["idle", "queued", "running", "needs_attention", "failed"]),
133
+ done: z.boolean().nullable(),
134
+ isDone: z.boolean(),
135
+ lastUsedAt: z.string(),
136
+ runStatus: z.enum(["idle", "queued", "running", "needs_attention", "waiting_for_browser", "failed"]),
133
137
  runError: z.string().nullable(),
134
138
  unreadCompletion: z.boolean(),
135
139
  projectId: z.string().nullable(),
@@ -1,3 +1,4 @@
1
+ import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
1
2
  import { sql } from "bun";
2
3
  import { createHash } from "node:crypto";
3
4
  import { z } from "zod";
@@ -217,13 +218,7 @@ export const buildMemoryLearningTranscript = (messages: AiStoredMessage[]): stri
217
218
  messages
218
219
  .flatMap((stored) => {
219
220
  const message = stored.message;
220
- if (
221
- stored.kind !== "message" ||
222
- message.role !== "user" ||
223
- stored.meta?.agentMessage ||
224
- stored.meta?.scheduledTask
225
- )
226
- return [];
221
+ if (stored.kind !== "message" || message.role !== "user" || stored.meta?.agentMessage || stored.meta?.scheduledTask) return [];
227
222
  return message.content.flatMap((part) => {
228
223
  if (typeof part === "string") return part.trim() ? [part.trim()] : [];
229
224
  if (part.type !== "text") return [];
@@ -305,7 +300,15 @@ const estimatedTokens = (systemPrompt: string, taskInput: string): number =>
305
300
 
306
301
  const workflowEvidenceKey = (pattern: AiMemoryWorkflowPattern): string =>
307
302
  createHash("sha256")
308
- .update(JSON.stringify([pattern.userId, pattern.capabilityId, pattern.resourceRef.type, pattern.resourceRef.id, ...[...pattern.turnIds].sort()]))
303
+ .update(
304
+ JSON.stringify([
305
+ pattern.userId,
306
+ pattern.capabilityId,
307
+ pattern.resourceRef.type,
308
+ pattern.resourceRef.id,
309
+ ...[...pattern.turnIds].sort(),
310
+ ]),
311
+ )
309
312
  .digest("hex");
310
313
 
311
314
  const toProposal = (change: z.infer<typeof AiMemoryLearningChangeSchema>): AiBackgroundMemoryProposal => ({
@@ -329,7 +332,8 @@ const applyChanges = async (
329
332
  !evidence.actions.some(
330
333
  (action) => action.resourceRef.type === change.resourceRef?.type && action.resourceRef.id === change.resourceRef.id,
331
334
  )
332
- ) continue;
335
+ )
336
+ continue;
333
337
  const results = await aiMemories.applyBackgroundProposal({
334
338
  userId: evidence.candidate.userId,
335
339
  sourceConversationId: evidence.candidate.conversationId,
@@ -376,9 +380,7 @@ const workflowPatternInput = async (
376
380
  return {
377
381
  source: evidence[0] ?? null,
378
382
  mutableMemoryIds: new Set(
379
- selected.memories
380
- .filter((memory) => memory.source === "background" && memory.priority === "normal")
381
- .map((memory) => memory.shortId),
383
+ selected.memories.filter((memory) => memory.source === "background" && memory.priority === "normal").map((memory) => memory.shortId),
382
384
  ),
383
385
  input: [
384
386
  `CAPABILITY: ${pattern.capabilityId}`,
@@ -395,7 +397,8 @@ const workflowPatternInput = async (
395
397
  const usageNumbers = (usage: RunAiStructuredResult<z.ZodType>["usage"], reserved: number) => {
396
398
  const inputTokens = usage?.input;
397
399
  const outputTokens = usage?.output;
398
- const total = usage?.total ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
400
+ const total =
401
+ usage?.total ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
399
402
  return { inputTokens, outputTokens, accountedTokens: total ?? reserved };
400
403
  };
401
404
 
@@ -414,8 +417,7 @@ export const learnAiMemoriesFromPrivateChats = async (
414
417
  }
415
418
 
416
419
  const configuredBudget =
417
- input.deps?.monthlyTokenBudget ??
418
- Number(await coreSettings.get<number>(AI_MEMORY_LEARNING_MONTHLY_TOKEN_BUDGET_SETTING_KEY));
420
+ input.deps?.monthlyTokenBudget ?? Number(await coreSettings.get<number>(AI_MEMORY_LEARNING_MONTHLY_TOKEN_BUDGET_SETTING_KEY));
419
421
  const monthlyTokenBudget =
420
422
  Number.isFinite(configuredBudget) && configuredBudget > 0
421
423
  ? Math.floor(configuredBudget)
@@ -432,9 +434,7 @@ export const learnAiMemoriesFromPrivateChats = async (
432
434
  let defaultLocale: Promise<string> | undefined;
433
435
  const resolveLearningLocale = (locale?: string | null): Promise<string> => {
434
436
  if (locale) return Promise.resolve(normalizeLocale(locale));
435
- defaultLocale ??= (input.deps?.readDefaultLocale ?? (() => coreSettings.get<string>("app.locale")))().then(
436
- normalizeLocale,
437
- );
437
+ defaultLocale ??= (input.deps?.readDefaultLocale ?? (() => coreSettings.get<string>("app.locale")))().then(normalizeLocale);
438
438
  return defaultLocale;
439
439
  };
440
440
 
@@ -514,6 +514,18 @@ export const learnAiMemoriesFromPrivateChats = async (
514
514
  if (status === "skipped") summary.skipped += 1;
515
515
  processedCandidates += 1;
516
516
  } catch (error) {
517
+ if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
518
+ summary.skipped += 1;
519
+ if (runId)
520
+ await aiMemoryLearningRuns.finish({
521
+ runId,
522
+ status: "skipped",
523
+ durationMs: Date.now() - startedAt,
524
+ changes: [],
525
+ accountedTokens: 0,
526
+ });
527
+ return summary;
528
+ }
517
529
  summary.failed += 1;
518
530
  processedCandidates += 1;
519
531
  await markFailed(candidate).catch(() => undefined);
@@ -564,7 +576,11 @@ export const learnAiMemoriesFromPrivateChats = async (
564
576
  if (!runId) continue;
565
577
  const result = await structured({
566
578
  task: "memory-learn-workflow",
567
- attribution: { userId: pattern.userId, conversationId: context.source.candidate.conversationId, turnId: context.source.candidate.turnId },
579
+ attribution: {
580
+ userId: pattern.userId,
581
+ conversationId: context.source.candidate.conversationId,
582
+ turnId: context.source.candidate.turnId,
583
+ },
568
584
  appId: "ai",
569
585
  systemPrompt: workflowPrompt,
570
586
  input: context.input,
@@ -578,15 +594,19 @@ export const learnAiMemoriesFromPrivateChats = async (
578
594
  if (result.output.workflow && (await learningEnabled(pattern.userId))) {
579
595
  const memoryIds = result.output.workflow.memoryIds;
580
596
  const action = memoryIds.length > 1 ? "merge" : memoryIds.length === 1 ? "replace" : "add";
581
- changes = await applyChanges(context.source, [
582
- {
583
- action,
584
- kind: "workflow",
585
- content: result.output.workflow.content,
586
- memoryIds,
587
- resourceRef: pattern.resourceRef,
588
- },
589
- ], context.mutableMemoryIds);
597
+ changes = await applyChanges(
598
+ context.source,
599
+ [
600
+ {
601
+ action,
602
+ kind: "workflow",
603
+ content: result.output.workflow.content,
604
+ memoryIds,
605
+ resourceRef: pattern.resourceRef,
606
+ },
607
+ ],
608
+ context.mutableMemoryIds,
609
+ );
590
610
  }
591
611
  await markAiWorkflowPatternReviewed(pattern);
592
612
  await aiMemoryLearningRuns.finish({
@@ -600,6 +620,18 @@ export const learnAiMemoriesFromPrivateChats = async (
600
620
  summary.updated += changes.filter((change) => change.action === "updated" || change.action === "merged").length;
601
621
  if (changes.length === 0) summary.skipped += 1;
602
622
  } catch (error) {
623
+ if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
624
+ summary.skipped += 1;
625
+ if (runId)
626
+ await aiMemoryLearningRuns.finish({
627
+ runId,
628
+ status: "skipped",
629
+ durationMs: Date.now() - startedAt,
630
+ changes: [],
631
+ accountedTokens: 0,
632
+ });
633
+ return summary;
634
+ }
603
635
  summary.failed += 1;
604
636
  if (runId) {
605
637
  await aiMemoryLearningRuns
@@ -0,0 +1,167 @@
1
+ import { editAiDraftText } from "./draft-content";
2
+ import { aiQuotas, AiQuotaError } from "./quotas";
3
+ import { aiChatAccessSubject, isAssistantChatTurn } from "./assistant-models";
4
+ import { aiInputToUserMessage, aiTurnInputToContent } from "./http";
5
+ import { canonicalizeAiConversationAttachments } from "./file-context";
6
+ import { aiResourceMarker } from "./resource-markers";
7
+ import { sql } from "bun";
8
+ import type { AiConversationService, AiDraftContentPart } from "./types";
9
+ import { aiConversations } from "./store";
10
+ import { logger } from "../services/logging";
11
+ import { AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT } from "./files-store";
12
+
13
+ const log = logger("ai:message-queue");
14
+ export const AI_MESSAGE_QUEUE_LIMIT = 32;
15
+ export class AiMessageQueueConflict extends Error {}
16
+
17
+ type Submission = Parameters<AiConversationService["submitChatTurn"]>[0];
18
+
19
+ export const migrateAiMessageQueue = async () => {
20
+ await sql`CREATE TABLE IF NOT EXISTS ai.queued_messages (
21
+ id uuid PRIMARY KEY, conversation_id uuid NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
22
+ position bigint GENERATED ALWAYS AS IDENTITY, submission jsonb NOT NULL, content jsonb NOT NULL,
23
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','failed','submitted','cancelled')),
24
+ error text, created_at timestamptz NOT NULL DEFAULT now()
25
+ )`.simple();
26
+ await sql`ALTER TABLE ai.queued_messages ADD COLUMN IF NOT EXISTS quota_checked_at TIMESTAMPTZ`.simple();
27
+ await sql`CREATE INDEX IF NOT EXISTS ai_queued_messages_pending ON ai.queued_messages(conversation_id, position) WHERE status IN ('pending','failed')`.simple();
28
+ await sql`CREATE TABLE IF NOT EXISTS ai.queued_message_files (
29
+ message_id uuid NOT NULL REFERENCES ai.queued_messages(id) ON DELETE CASCADE,
30
+ path text NOT NULL, bytes bytea NOT NULL, media_type text NOT NULL, size bigint NOT NULL,
31
+ origin text NOT NULL, dictation_recorded_at timestamptz, updated_at timestamptz NOT NULL, version bigint NOT NULL,
32
+ PRIMARY KEY(message_id, path)
33
+ )`.simple();
34
+ await sql`DROP TRIGGER IF EXISTS ai_live_queued_messages_changed ON ai.queued_messages`.simple();
35
+ await sql`CREATE TRIGGER ai_live_queued_messages_changed AFTER INSERT OR DELETE ON ai.queued_messages
36
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-detail,conversation-list')`.simple();
37
+ await sql`DROP TRIGGER IF EXISTS ai_live_queued_messages_updated ON ai.queued_messages`.simple();
38
+ await sql`CREATE TRIGGER ai_live_queued_messages_updated AFTER UPDATE ON ai.queued_messages
39
+ FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.error IS DISTINCT FROM NEW.error OR OLD.content IS DISTINCT FROM NEW.content)
40
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-detail,conversation-list')`.simple();
41
+ };
42
+
43
+ export const listQueuedMessages = async (conversationId: string) => {
44
+ const rows = await sql<{ id: string; content: AiDraftContentPart[]; error: string | null }[]>`
45
+ SELECT id, content, error FROM ai.queued_messages
46
+ WHERE conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') ORDER BY position`;
47
+ return rows.map((row) => ({ id: row.id, content: row.content, failed: Boolean(row.error) }));
48
+ };
49
+
50
+ export const queuedMessageAccepted = async (conversationId: string, id: string) => {
51
+ const [row] = await sql`SELECT id FROM ai.queued_messages WHERE id=${id}::uuid AND conversation_id=${conversationId}::uuid`;
52
+ return Boolean(row);
53
+ };
54
+
55
+ export const enqueueChatMessage = async (id: string, input: Submission, content: AiDraftContentPart[]) =>
56
+ sql.begin(async (tx) => {
57
+ const [conversation] = await tx<{ draft_revision: number; project_id: string | null }[]>`
58
+ SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE`;
59
+ if (!conversation) throw new AiMessageQueueConflict("Conversation not found.");
60
+ const [existing] =
61
+ await tx`SELECT id FROM ai.queued_messages WHERE id = ${id}::uuid AND conversation_id = ${input.conversationId}::uuid`;
62
+ if (existing) return;
63
+ if (Number(conversation.draft_revision) !== input.expectedDraftRevision)
64
+ throw new AiMessageQueueConflict("Conversation draft changed before the message was queued.");
65
+ if (conversation.project_id !== input.expectedProjectId)
66
+ throw new AiMessageQueueConflict("Conversation Project changed before the message was queued.");
67
+ const [count] = await tx<
68
+ { count: number }[]
69
+ >`SELECT count(*)::int AS count FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')`;
70
+ if (count!.count >= AI_MESSAGE_QUEUE_LIMIT) throw new AiMessageQueueConflict("The message queue is full (32 messages).");
71
+ const [size] = await tx<
72
+ { size: number }[]
73
+ >`SELECT COALESCE(sum(f.size),0)::float8 AS size FROM ai.queued_message_files f JOIN ai.queued_messages q ON q.id = f.message_id WHERE q.conversation_id = ${input.conversationId}::uuid`;
74
+ if (
75
+ Number(size!.size) + (input.runConfig.files?.attached ?? []).reduce((sum, file) => sum + file.size, 0) >
76
+ AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT
77
+ )
78
+ throw new AiMessageQueueConflict("Queued attachments exceed the conversation file budget.");
79
+ const { expectedDraftRevision, ...submission } = input;
80
+ await tx`INSERT INTO ai.queued_messages(id, conversation_id, submission, content) VALUES (${id}::uuid, ${input.conversationId}::uuid, (${JSON.stringify(submission)}::text)::jsonb, (${JSON.stringify(content)}::text)::jsonb)`;
81
+ for (const file of input.runConfig.files?.attached ?? []) {
82
+ const copied =
83
+ await tx`INSERT INTO ai.queued_message_files(message_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
84
+ SELECT ${id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.files
85
+ WHERE conversation_id = ${input.conversationId}::uuid AND path = ${file.path} AND version = ${file.version ?? -1} RETURNING path`;
86
+ if (!copied.length) throw new AiMessageQueueConflict("An attached file changed before the message was queued.");
87
+ }
88
+ await tx`UPDATE ai.conversations SET draft_content = '[]'::jsonb, draft_revision = draft_revision + 1, draft_updated_at = now(), last_used_at = now(), done = CASE WHEN done IS TRUE THEN NULL ELSE done END WHERE id = ${input.conversationId}::uuid`;
89
+ });
90
+
91
+ /** Cancellation and retry share the conversation lock with promotion. */
92
+ export const updateQueuedMessage = async (conversationId: string, id: string, action: "cancel" | "retry") =>
93
+ sql.begin(async (tx) => {
94
+ await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId}::uuid FOR UPDATE`;
95
+ const changed = await tx`UPDATE ai.queued_messages SET status = ${action === "cancel" ? "cancelled" : "pending"}, error = NULL
96
+ WHERE id = ${id}::uuid AND conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') RETURNING id`;
97
+ if (action === "cancel" && changed.length) {
98
+ await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${id}::uuid`;
99
+ await tx`UPDATE ai.queued_messages SET submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${id}::uuid`;
100
+ }
101
+ return Boolean(changed.length);
102
+ });
103
+
104
+ /** Edit only queued text; immutable attachments and the independent composer draft stay intact. */
105
+ export const editQueuedMessage = async (conversationId: string, id: string, text: string) =>
106
+ sql.begin(async (tx) => {
107
+ await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId}::uuid FOR UPDATE`;
108
+ const [row] = await tx<{ submission: Submission; content: AiDraftContentPart[] }[]>`SELECT submission, content FROM ai.queued_messages
109
+ WHERE id = ${id}::uuid AND conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') FOR UPDATE`;
110
+ if (!row) return false;
111
+ const content = editAiDraftText(row.content, text);
112
+ const parts = content.map((part) =>
113
+ part.type === "text"
114
+ ? part
115
+ : part.type === "project-file" ? { type: "text" as const, text: `Project file: ${part.path}` }
116
+ : part.type === "file"
117
+ ? { type: "attachment" as const, path: part.path, mediaType: part.mediaType, size: part.size }
118
+ : { type: "text" as const, text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }) },
119
+ );
120
+ const { message } = aiInputToUserMessage(aiTurnInputToContent({ content: parts }));
121
+ const userMessage = canonicalizeAiConversationAttachments(message, row.submission.runConfig.files);
122
+ if (userMessage.role !== "user") throw new Error("Queued message must be a user message.");
123
+ const submission = { ...row.submission, userMessage, runConfig: { ...row.submission.runConfig, input: userMessage.content, selectedSkillIds: [...new Set(content.flatMap(part => part.type === "resource" && part.ref.type === "core.ai.skill" ? [part.ref.id] : []))] } };
124
+ await tx`UPDATE ai.queued_messages SET submission = (${JSON.stringify(submission)}::text)::jsonb,
125
+ content = (${JSON.stringify(content)}::text)::jsonb WHERE id = ${id}::uuid`;
126
+ return true;
127
+ });
128
+
129
+ /** Existing turn creation owns execution and recovery; the DB lock owns FIFO and deduplication. */
130
+ export const drainQueuedMessages = async (
131
+ enqueue: (job: { conversationId: string; turnId: string }) => Promise<unknown>,
132
+ conversationId?: string,
133
+ ) => {
134
+ const rows = await sql<{ id: string; conversation_id: string; submission: Submission }[]>`
135
+ SELECT q.id, q.conversation_id, q.submission FROM ai.queued_messages q
136
+ JOIN ai.conversations c ON c.id = q.conversation_id
137
+ WHERE (q.status = 'pending' OR (q.status = 'failed' AND q.error IN ('quota_exhausted','quota_usage_unknown'))) AND c.archived_at IS NULL AND c.created_by_user_id IS NOT NULL
138
+ AND (${conversationId ?? null}::uuid IS NULL OR q.conversation_id = ${conversationId ?? null}::uuid)
139
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages earlier WHERE earlier.conversation_id = q.conversation_id AND earlier.position < q.position AND earlier.status IN ('pending','failed'))
140
+ AND NOT EXISTS (SELECT 1 FROM ai.turns t WHERE t.conversation_id = q.conversation_id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued','running','waiting_for_action'))
141
+ ORDER BY q.quota_checked_at NULLS FIRST, q.position LIMIT ${AI_MESSAGE_QUEUE_LIMIT}`;
142
+ for (const row of rows) {
143
+ try {
144
+ const quotaSubject = aiChatAccessSubject(row.submission.runConfig.actor);
145
+ if (isAssistantChatTurn(row.submission.runConfig) && quotaSubject)
146
+ await aiQuotas.assertAllowed(quotaSubject, row.submission.modelProfileId);
147
+ await sql`UPDATE ai.queued_messages SET status='pending', error=NULL WHERE id=${row.id}::uuid AND status='failed' AND error IN ('quota_exhausted','quota_usage_unknown')`;
148
+ const result = await aiConversations.submitChatTurn({ ...row.submission, queuedMessageId: row.id });
149
+ await enqueue({ conversationId: row.conversation_id, turnId: result.turn.id });
150
+ } catch (error) {
151
+ if (error instanceof AiQuotaError) {
152
+ await sql`UPDATE ai.queued_messages SET status='failed', error=${error.code}, quota_checked_at=clock_timestamp() WHERE id=${row.id}::uuid AND status IN ('pending','failed')`;
153
+ continue;
154
+ }
155
+ // Another dispatcher may have consumed it or a new foreground turn may have won.
156
+ const reason = error instanceof Error ? error.message : String(error);
157
+ if (reason === "Queued message is no longer ready." || reason.includes("idx_ai_turns_one_active_per_conversation")) continue;
158
+ await sql`UPDATE ai.queued_messages SET status = 'failed', error = 'dispatch_failed' WHERE id = ${row.id}::uuid AND status = 'pending'`;
159
+ log.error("Queued message dispatch failed", {
160
+ conversationId: row.conversation_id,
161
+ messageId: row.id,
162
+ code: "queue_dispatch_failed",
163
+ errorType: error instanceof Error ? error.name : "unknown",
164
+ });
165
+ }
166
+ }
167
+ };