@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/routes.ts CHANGED
@@ -1,3 +1,12 @@
1
+ import { getAiChatQuotas } from "./chat-quotas";
2
+ import { enqueueChatMessage, listQueuedMessages, updateQueuedMessage, editQueuedMessage, queuedMessageAccepted, AiMessageQueueConflict } from "./message-queue";
3
+ import { prepareAiChatTurn, wakeAiMessageQueue } from "./runtime";
4
+ import { bodyLimit } from "hono/body-limit";
5
+ import { AI_AUDIO_MAX_BYTES } from "./audio-format";
6
+ import { aiDictations, AiDictationConflict, AiDictationStartSchema, AiDictationListSchema } from "./dictations";
7
+ import { enqueueAiDictation } from "./dictation-runtime";
8
+ import { resolveAiAudioModel } from "./transcription";
9
+ import { aiModelAccess } from "./model-access";
1
10
  import { type Context, Hono } from "hono";
2
11
  import { z } from "zod";
3
12
  import { listCapabilities } from "../_internal/registry";
@@ -16,6 +25,7 @@ import {
16
25
  v,
17
26
  } from "../server";
18
27
  import { coreSettings } from "../services/settings/api";
28
+ import { logger } from "../services/logging";
19
29
  import type { AiToolApprovalContext } from "./approvals";
20
30
  import { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
21
31
  import { buildAiCapabilityCatalog } from "./capabilities";
@@ -102,6 +112,10 @@ const ConversationListQuerySchema = z
102
112
  .enum(["true", "false"])
103
113
  .transform((value) => value === "true")
104
114
  .optional(),
115
+ done: z
116
+ .enum(["true", "false"])
117
+ .transform((value) => value === "true")
118
+ .optional(),
105
119
  status: z.enum(["running", "needs_attention", "failed", "unread"]).optional(),
106
120
  projectId: z.string().regex(AI_SHORT_ID_PATTERN).optional(),
107
121
  unassigned: z
@@ -123,6 +137,7 @@ const ConversationMetadataInputSchema = z.object({
123
137
  });
124
138
 
125
139
  const ConversationProjectInputSchema = z.object({
140
+ onlyUnassigned: z.boolean().optional(),
126
141
  projectId: z.string().regex(AI_SHORT_ID_PATTERN).nullable(),
127
142
  });
128
143
 
@@ -212,12 +227,13 @@ const rememberLastUsedModel = (actor: RequestActor, modelProfileId: string | nul
212
227
  };
213
228
 
214
229
  const conversationDetail = async (conversation: AiConversation, subject: AccessSubject) => {
215
- const [state, timeline] = await Promise.all([
230
+ const [state, timeline, publicView] = await Promise.all([
216
231
  loadAiStreamState(conversation),
217
232
  aiConversations.listConversationTimeline({ conversationId: conversation.id }),
233
+ publicConversationFor(conversation, subject),
218
234
  ]);
219
235
  return {
220
- conversation: await publicConversationFor(conversation, subject),
236
+ conversation: publicView,
221
237
  messages: state.messages.map((message) => ({ ...message, id: message.shortId })),
222
238
  hasMoreMessages: state.hasMoreMessages ?? false,
223
239
  activeTurn: state.activeTurn,
@@ -284,6 +300,10 @@ export const aiRoutes = (() => {
284
300
  .get("/status", async (c) => {
285
301
  return respond(c, ok(await assistantAiSettingsState(c.get("accessSubject"))));
286
302
  })
303
+ .get("/quotas", async (c) => {
304
+ c.header("Cache-Control", "no-store");
305
+ return c.json(await getAiChatQuotas(c.get("accessSubject")));
306
+ })
287
307
  .get("/models", async (c) => {
288
308
  return respond(c, ok(await listAssistantAiModels(c.get("accessSubject"))));
289
309
  })
@@ -428,6 +448,7 @@ export const aiRoutes = (() => {
428
448
  ownerUserId: ctx.ownerUserId,
429
449
  search: query.q,
430
450
  archived: query.archived,
451
+ done: query.done,
431
452
  status: query.status,
432
453
  projectId: project?.id,
433
454
  unassigned: query.unassigned,
@@ -448,6 +469,7 @@ export const aiRoutes = (() => {
448
469
  ownerUserId: ctx.ownerUserId,
449
470
  search: query.q,
450
471
  archived: query.archived,
472
+ done: query.done,
451
473
  status: query.status,
452
474
  projectId: project?.id,
453
475
  unassigned: query.unassigned,
@@ -552,7 +574,7 @@ export const aiRoutes = (() => {
552
574
  if (ctx instanceof Response) return ctx;
553
575
  const conversation = await loadConversation(c, ctx);
554
576
  if (!conversation) return notFound(c);
555
- const messageId = c.req.param("messageId");
577
+ const messageId = (c.req.param("messageId") ?? "");
556
578
  if (!messageId) return respond(c, fail(err.badInput("Invalid message id.")));
557
579
  const input = c.req.valid("json");
558
580
  const feedback = await aiConversations.setMessageFeedback({
@@ -571,7 +593,7 @@ export const aiRoutes = (() => {
571
593
  if (ctx instanceof Response) return ctx;
572
594
  const conversation = await loadConversation(c, ctx);
573
595
  if (!conversation) return notFound(c);
574
- const messageId = c.req.param("messageId");
596
+ const messageId = (c.req.param("messageId") ?? "");
575
597
  if (!messageId) return respond(c, fail(err.badInput("Invalid message id.")));
576
598
  return (await aiConversations.clearMessageFeedback({
577
599
  conversationId: conversation.id,
@@ -650,8 +672,10 @@ export const aiRoutes = (() => {
650
672
  conversationId: conversation.id,
651
673
  ownerUserId: ctx.ownerUserId,
652
674
  projectId: project?.id ?? null,
675
+ onlyUnassigned: body.onlyUnassigned,
653
676
  });
654
677
  if (!result.ok) {
678
+ if (result.reason === "already_assigned") return respond(c, fail(err.conflict("This chat already belongs to a Project.")));
655
679
  if (result.reason === "active_turn") {
656
680
  return respond(c, fail(err.conflict("Wait for the active turn before changing the Project.")));
657
681
  }
@@ -683,6 +707,22 @@ export const aiRoutes = (() => {
683
707
  });
684
708
  return updated ? respond(c, ok(await publicConversationFor(updated, c.get("accessSubject")))) : notFound(c);
685
709
  })
710
+ .put("/conversations/:conversationId/done", v("json", z.object({ done: z.boolean().nullable() })), async (c) => {
711
+ const ctx = await resolveContext(c);
712
+ if (ctx instanceof Response) return ctx;
713
+ const conversation = await loadConversation(c, ctx);
714
+ if (!conversation) return notFound(c);
715
+ const result = await aiConversations.setConversationDone({
716
+ conversationId: conversation.id,
717
+ ownerUserId: ctx.ownerUserId,
718
+ done: c.req.valid("json").done,
719
+ });
720
+ if (!result.ok)
721
+ return result.reason === "active_turn"
722
+ ? respond(c, fail(err.conflict("Stop the current response before marking this chat done.")))
723
+ : notFound(c);
724
+ return respond(c, ok(await publicConversationFor(result.conversation, c.get("accessSubject"))));
725
+ })
686
726
  .post("/conversations/:conversationId/archive", async (c) => {
687
727
  const ctx = await resolveContext(c);
688
728
  if (ctx instanceof Response) return ctx;
@@ -720,6 +760,37 @@ export const aiRoutes = (() => {
720
760
  });
721
761
  return viewed ? respond(c, ok({ ok: true })) : notFound(c);
722
762
  })
763
+ .get("/conversations/:conversationId/queue", async (c) => {
764
+ const ctx = await resolveContext(c);
765
+ if (ctx instanceof Response) return ctx;
766
+ const conversation = await loadConversation(c, ctx);
767
+ return conversation ? respond(c, ok(await listQueuedMessages(conversation.id))) : notFound(c);
768
+ })
769
+ .patch("/conversations/:conversationId/queue/:messageId", v("json", z.object({text:z.string().trim().min(1).max(20000)}).strict()), async (c) => {
770
+ const ctx = await resolveContext(c);
771
+ if (ctx instanceof Response) return ctx;
772
+ const conversation = await loadConversation(c, ctx);
773
+ if (!conversation || !z.uuid().safeParse(c.req.param("messageId")).success) return notFound(c);
774
+ return await editQueuedMessage(conversation.id,(c.req.param("messageId") ?? ""),c.req.valid("json").text) ? respond(c,ok({ok:true})) : notFound(c);
775
+ })
776
+ .delete("/conversations/:conversationId/queue/:messageId", async (c) => {
777
+ const ctx = await resolveContext(c);
778
+ if (ctx instanceof Response) return ctx;
779
+ const conversation = await loadConversation(c, ctx);
780
+ if (!conversation || !z.uuid().safeParse(c.req.param("messageId")).success) return notFound(c);
781
+ if (!await updateQueuedMessage(conversation.id, (c.req.param("messageId") ?? ""), "cancel")) return notFound(c);
782
+ wakeAiMessageQueue(conversation.id);
783
+ return respond(c, ok({ok:true}));
784
+ })
785
+ .post("/conversations/:conversationId/queue/:messageId/retry", async (c) => {
786
+ const ctx = await resolveContext(c);
787
+ if (ctx instanceof Response) return ctx;
788
+ const conversation = await loadConversation(c, ctx);
789
+ if (!conversation || !z.uuid().safeParse(c.req.param("messageId")).success) return notFound(c);
790
+ if (!await updateQueuedMessage(conversation.id, (c.req.param("messageId") ?? ""), "retry")) return notFound(c);
791
+ wakeAiMessageQueue(conversation.id);
792
+ return respond(c, ok({ok:true}));
793
+ })
723
794
  .put("/conversations/:conversationId/draft", v("json", AiSaveConversationDraftInputSchema), async (c) => {
724
795
  const ctx = await resolveContext(c);
725
796
  if (ctx instanceof Response) return ctx;
@@ -749,6 +820,7 @@ export const aiRoutes = (() => {
749
820
  const conversation = await loadConversation(c, ctx);
750
821
  if (!conversation) return notFound(c);
751
822
  const body = c.req.valid("json");
823
+ if (body.queueId && await queuedMessageAccepted(conversation.id, body.queueId)) return respond(c,ok({queued:true}));
752
824
  if (conversation.draft.revision !== body.draftRevision) {
753
825
  return respond(c, fail(err.conflict("The conversation draft changed in another session.")));
754
826
  }
@@ -756,6 +828,7 @@ export const aiRoutes = (() => {
756
828
  const resourceParts = conversation.draft.content.filter((part) => part.type === "resource");
757
829
  const turnContent = conversation.draft.content.map((part) => {
758
830
  if (part.type === "text") return { type: "text" as const, text: part.text };
831
+ if (part.type === "project-file") return { type: "text" as const, text: `Project file: ${part.path}` };
759
832
  if (part.type === "file") {
760
833
  return { type: "attachment" as const, path: part.path, mediaType: part.mediaType, size: part.size };
761
834
  }
@@ -768,8 +841,8 @@ export const aiRoutes = (() => {
768
841
  const project = conversation.projectId ? await aiProjects.snapshot(conversation.projectId, c.get("accessSubject")) : null;
769
842
  if (conversation.projectId && !project) return respond(c, fail(err.notFound("Project")));
770
843
  try {
771
- const result = await submitAiChatTurn({
772
- assistantChat: true,
844
+ const submission = {
845
+ assistantChat: true as const,
773
846
  conversationId: conversation.id,
774
847
  chatId: conversation.shortId,
775
848
  input,
@@ -780,7 +853,7 @@ export const aiRoutes = (() => {
780
853
  modelPolicy: ctx.modelPolicy,
781
854
  project: project ?? undefined,
782
855
  clientToolIds: body.clientToolIds,
783
- toolSource: { kind: "default", appTools: true },
856
+ toolSource: { kind: "default" as const, appTools: true },
784
857
  toolApprovalContext: ctx.toolApprovalContext,
785
858
  expectedDraftRevision: body.draftRevision,
786
859
  expectedProjectId: conversation.projectId,
@@ -788,7 +861,13 @@ export const aiRoutes = (() => {
788
861
  expectedFiles: conversation.draft.content.flatMap((part) =>
789
862
  part.type === "file" ? [{ path: part.path, version: part.version }] : [],
790
863
  ),
791
- });
864
+ };
865
+ if (body.queueId) {
866
+ await enqueueChatMessage(body.queueId, await prepareAiChatTurn(submission), conversation.draft.content);
867
+ wakeAiMessageQueue(conversation.id);
868
+ return respond(c, ok({ queued: true }));
869
+ }
870
+ const result = await submitAiChatTurn(submission);
792
871
  rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
793
872
  return respond(
794
873
  c,
@@ -803,6 +882,7 @@ export const aiRoutes = (() => {
803
882
  201,
804
883
  );
805
884
  } catch (error) {
885
+ if (error instanceof AiMessageQueueConflict || (error instanceof Error && error.message === "Queued messages must be sent first.")) return respond(c,fail(err.conflict(error.message)));
806
886
  return toAiErrorResponse(c, error);
807
887
  }
808
888
  })
@@ -832,7 +912,7 @@ export const aiRoutes = (() => {
832
912
  if (ctx instanceof Response) return ctx;
833
913
  const conversation = await loadConversation(c, ctx);
834
914
  if (!conversation) return notFound(c);
835
- const messageId = c.req.param("messageId");
915
+ const messageId = (c.req.param("messageId") ?? "");
836
916
 
837
917
  const messages = await aiConversations.listMessages({ conversationId: conversation.id });
838
918
  const target = messages.find((m) => m.shortId === messageId);
@@ -881,6 +961,7 @@ export const aiRoutes = (() => {
881
961
  truncateFromSeq: target.seq,
882
962
  fileSnapshot: originalRunConfig?.kind !== "compact" ? originalRunConfig?.files : undefined,
883
963
  retrySourceTurnId: target.loopId ?? undefined,
964
+ clientToolIds: originalRunConfig?.kind !== "compact" ? originalRunConfig?.clientToolIds : undefined,
884
965
  });
885
966
  rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
886
967
  return respond(
@@ -904,7 +985,7 @@ export const aiRoutes = (() => {
904
985
  if (ctx instanceof Response) return ctx;
905
986
  const conversation = await loadConversation(c, ctx);
906
987
  if (!conversation) return notFound(c);
907
- const messageId = c.req.param("messageId");
988
+ const messageId = (c.req.param("messageId") ?? "");
908
989
 
909
990
  const messages = await aiConversations.listMessages({ conversationId: conversation.id });
910
991
  const target = messages.find((m) => m.shortId === messageId);
@@ -1029,6 +1110,123 @@ export const aiRoutes = (() => {
1029
1110
  const files = await aiFileStore.list({ conversationId: conversation.id, prefix: c.req.valid("query").prefix ?? "/" });
1030
1111
  return respond(c, ok({ files, totalBytes: await aiFileStore.totalBytes(conversation.id) }));
1031
1112
  })
1113
+ .post("/conversations/:conversationId/dictations", bodyLimit({ maxSize: AI_AUDIO_MAX_BYTES + 65_536 }), async (c) => {
1114
+ const ctx = await resolveContext(c);
1115
+ if (ctx instanceof Response) return ctx;
1116
+ const conversation = await loadConversation(c, ctx);
1117
+ if (!conversation) return notFound(c);
1118
+ try {
1119
+ const form = await c.req.formData();
1120
+ const file = form.get("file");
1121
+ if (!(file instanceof File)) return respond(c, fail(err.badInput("Attach one audio recording.")));
1122
+ const options = AiDictationStartSchema.parse({
1123
+ operationId: form.get("operationId"),
1124
+ language: form.get("language") ?? undefined,
1125
+ });
1126
+ const dictation = await aiDictations.start({
1127
+ ...options,
1128
+ conversationId: conversation.id,
1129
+ userId: ctx.ownerUserId,
1130
+ bytes: new Uint8Array(await file.arrayBuffer()),
1131
+ resolveModel: async () => {
1132
+ const model = await resolveAiAudioModel({ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries });
1133
+ await aiModelAccess.assertAllowed(model.profile.id, c.get("accessSubject"));
1134
+ return model;
1135
+ },
1136
+ });
1137
+ await enqueueAiDictation(dictation.id).catch(() => undefined); // The DB sweep heals failed delivery.
1138
+ return respond(c, ok(dictation));
1139
+ } catch (error) {
1140
+ return respond(
1141
+ c,
1142
+ fail(
1143
+ error instanceof AiDictationConflict
1144
+ ? err.conflict(error.message)
1145
+ : err.badInput(error instanceof Error ? error.message : "Recording upload failed."),
1146
+ ),
1147
+ );
1148
+ }
1149
+ })
1150
+ .post("/conversations/:conversationId/dictations/discard", v("json", z.object({ operationId: z.uuid() })), async (c) => {
1151
+ const ctx = await resolveContext(c);
1152
+ if (ctx instanceof Response) return ctx;
1153
+ const conversation = await loadConversation(c, ctx);
1154
+ if (!conversation) return notFound(c);
1155
+ return respond(c, ok(await aiDictations.discardOperation({
1156
+ conversationId: conversation.id, userId: ctx.ownerUserId, operationId: c.req.valid("json").operationId,
1157
+ })));
1158
+ })
1159
+ .get("/conversations/:conversationId/dictations", v("query", AiDictationListSchema), async (c) => {
1160
+ const ctx = await resolveContext(c);
1161
+ if (ctx instanceof Response) return ctx;
1162
+ const conversation = await loadConversation(c, ctx);
1163
+ if (!conversation) return notFound(c);
1164
+ return respond(
1165
+ c,
1166
+ ok(await aiDictations.list({ ...c.req.valid("query"), conversationId: conversation.id, userId: ctx.ownerUserId })),
1167
+ );
1168
+ })
1169
+ .get("/conversations/:conversationId/dictations/:dictationId", async (c) => {
1170
+ const ctx = await resolveContext(c);
1171
+ if (ctx instanceof Response) return ctx;
1172
+ const conversation = await loadConversation(c, ctx);
1173
+ if (!conversation) return notFound(c);
1174
+ const result = await aiDictations.get({
1175
+ conversationId: conversation.id,
1176
+ userId: ctx.ownerUserId,
1177
+ id: z.string().regex(AI_SHORT_ID_PATTERN).parse(c.req.param("dictationId")),
1178
+ });
1179
+ return result ? respond(c, ok(result)) : notFound(c);
1180
+ })
1181
+ .post("/conversations/:conversationId/dictations/:dictationId/apply", v("json", AiSaveConversationDraftInputSchema), async (c) => {
1182
+ const ctx = await resolveContext(c);
1183
+ if (ctx instanceof Response) return ctx;
1184
+ const conversation = await loadConversation(c, ctx);
1185
+ if (!conversation) return notFound(c);
1186
+ try {
1187
+ return respond(
1188
+ c,
1189
+ ok(
1190
+ await aiDictations.apply({
1191
+ ...c.req.valid("json"),
1192
+ conversationId: conversation.id,
1193
+ userId: ctx.ownerUserId,
1194
+ id: z.string().regex(AI_SHORT_ID_PATTERN).parse(c.req.param("dictationId")),
1195
+ }),
1196
+ ),
1197
+ );
1198
+ } catch (error) {
1199
+ return respond(
1200
+ c,
1201
+ fail(
1202
+ error instanceof AiDictationConflict
1203
+ ? err.conflict(error.message)
1204
+ : err.badInput(error instanceof Error ? error.message : "Dictation could not be inserted."),
1205
+ ),
1206
+ );
1207
+ }
1208
+ })
1209
+ .post(
1210
+ "/conversations/:conversationId/dictations/:dictationId/action",
1211
+ v("json", z.object({ action: z.enum(["discard", "retry"]) })),
1212
+ async (c) => {
1213
+ const ctx = await resolveContext(c);
1214
+ if (ctx instanceof Response) return ctx;
1215
+ const conversation = await loadConversation(c, ctx);
1216
+ if (!conversation) return notFound(c);
1217
+ const input = {
1218
+ conversationId: conversation.id,
1219
+ userId: ctx.ownerUserId,
1220
+ id: z.string().regex(AI_SHORT_ID_PATTERN).parse(c.req.param("dictationId")),
1221
+ };
1222
+ if (c.req.valid("json").action === "discard") await aiDictations.discard(input);
1223
+ else {
1224
+ await aiDictations.retry(input);
1225
+ await enqueueAiDictation(input.id).catch(() => undefined);
1226
+ }
1227
+ return respond(c, ok({ success: true }));
1228
+ },
1229
+ )
1032
1230
  .post("/conversations/:conversationId/files", async (c) => {
1033
1231
  const ctx = await resolveContext(c);
1034
1232
  if (ctx instanceof Response) return ctx;
@@ -1042,7 +1240,9 @@ export const aiRoutes = (() => {
1042
1240
  return respond(c, fail(err.badInput(`File exceeds the ${Math.floor(AI_FILES_MAX_FILE_BYTES_DEFAULT / (1024 * 1024))} MB limit`)));
1043
1241
  }
1044
1242
  const name = (file.name || "upload").replaceAll("/", "_").replaceAll("\\", "_").replaceAll("\0", "").slice(0, 160) || "upload";
1045
- const path = normalizeAiFilePath(`/${name}`);
1243
+ const directory=form?.get("directory") ?? "/";
1244
+ if(typeof directory!=="string")return respond(c,fail(err.badInput("Invalid directory")));
1245
+ const path = normalizeAiFilePath(`${directory.replace(/\/$/,"")}/${name}`);
1046
1246
  if (!path) return respond(c, fail(err.badInput("Invalid file name")));
1047
1247
  if (aiProjectFilePathFromMount(path) !== null) return respond(c, fail(err.badInput("The /project namespace is reserved.")));
1048
1248
 
@@ -1055,6 +1255,10 @@ export const aiRoutes = (() => {
1055
1255
  });
1056
1256
  return respond(c, ok({ file: stat }));
1057
1257
  } catch (error) {
1258
+ logger("ai:files").warn("Conversation upload failed", {
1259
+ code: "file_upload_failed", conversationId: conversation.id,
1260
+ errorType: error instanceof Error ? error.name : "unknown",
1261
+ });
1058
1262
  return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Upload failed")));
1059
1263
  }
1060
1264
  })
@@ -0,0 +1,10 @@
1
+ /** Preserve why execution stopped instead of reporting an operator deadline as a user abort. */
2
+ export class AiRunTimeout extends Error {
3
+ constructor(readonly budgetMs: number | null) { super("AI_RUN_TIMEOUT"); }
4
+ messageFor(locale?: string): string {
5
+ const minutes = this.budgetMs ? this.budgetMs / 60_000 : null;
6
+ return locale?.startsWith("de")
7
+ ? `Laufzeitlimit${minutes ? ` von ${minutes} Minuten` : ""} erreicht. Du kannst die Aufgabe mit einer neuen Nachricht fortsetzen.`
8
+ : `Run time limit${minutes ? ` of ${minutes} minutes` : ""} reached. You can continue the task with a new message.`;
9
+ }
10
+ }
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES,
3
3
  createCloudAiCardTool,
4
+ createCloudAiCodeTools,
4
5
  createCloudAiLocalBashTool,
5
6
  createCloudAiSurveyTool,
6
7
  createCloudAiTextEditorTool,
@@ -37,3 +38,5 @@ export {
37
38
  createCloudAiMarkdownToPdfTool,
38
39
  } from "./markdown-pdf-tool";
39
40
  export { createCloudAiViewImageTool } from "./vision-tool";
41
+
42
+ export { createCloudAiTranscribeAudioTool, CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema } from "./audio-tool";
package/src/ai/runtime.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { parseAiResourceMarker } from "./resource-markers";
2
+ import { aiQuotas } from "./quotas";
3
+ import { drainQueuedMessages } from "./message-queue";
4
+ import { coreSettings } from "../services/settings/api";
5
+ import { startAiDictationRuntime } from "./dictation-runtime";
1
6
  import { isDeepStrictEqual } from "node:util";
2
7
  import type { InboundEvent, Input, Message } from "@k2b/nessi";
3
8
  import type { QueueMessage } from "@k2b/sync";
@@ -44,7 +49,7 @@ const AI_TURN_LEASE_MS = 45_000;
44
49
  const AI_TURN_HEARTBEAT_MS = 3_000;
45
50
  const AI_TURN_WORKER_CONCURRENCY = 8;
46
51
  const AI_TURN_MAX_ATTEMPTS = 5;
47
- const AI_TURN_RUN_BUDGET_MS = 10 * 60_000;
52
+
48
53
  const AI_SWEEP_INTERVAL_MS = 15_000;
49
54
 
50
55
  // ---------------------------------------------------------------------------
@@ -65,7 +70,14 @@ const aiTurnQueue = lazySync((sync) => {
65
70
 
66
71
  // No idempotency key: the DB claim is the only gate, so re-enqueues (recovery,
67
72
  // continuation, stale-sweep) are always allowed and never silently swallowed.
68
- const enqueueAiTurn = (job: AiTurnJob): Promise<unknown> => aiTurnQueue().send({ data: job, orderingKey: job.conversationId });
73
+ const enqueueAiTurn = (job: AiTurnJob): Promise<unknown> => aiTurnQueue().send({ data: job, orderingKey: job.turnId });
74
+
75
+ /** Wake the durable queue promptly; the periodic sweep retries a failed wake-up. */
76
+ export const wakeAiMessageQueue = (conversationId: string): void => {
77
+ void drainQueuedMessages(enqueueAiTurn, conversationId).catch(() =>
78
+ log.warn("AI message queue wake-up failed", { code: "queue_wakeup_failed", conversationId }),
79
+ );
80
+ };
69
81
 
70
82
  /** Enqueue a turn created atomically by another durable AI workflow. */
71
83
  export const enqueueExistingAiTurn = (input: AiTurnJob): Promise<unknown> => enqueueAiTurn(input);
@@ -103,7 +115,7 @@ export type SubmitAiChatTurnInput = {
103
115
  retrySourceTurnId?: string;
104
116
  };
105
117
 
106
- export const submitAiChatTurn = async (input: SubmitAiChatTurnInput): Promise<{ turn: AiTurn; message: AiStoredMessage }> => {
118
+ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
107
119
  if (input.clientToolIds?.length && input.toolSource?.kind !== "default") {
108
120
  throw new Error("Optional client tools require the default tool source.");
109
121
  }
@@ -128,8 +140,14 @@ export const submitAiChatTurn = async (input: SubmitAiChatTurnInput): Promise<{
128
140
  modelPolicy: input.modelPolicy,
129
141
  requestedModelId,
130
142
  });
143
+ const quotaSubject = aiChatAccessSubject(input.actor);
144
+ if (input.assistantChat && quotaSubject) await aiQuotas.assertAllowed(quotaSubject, resolved.profile.id);
131
145
  const runConfig: AiChatTurnRunConfig = {
132
146
  kind: "chat",
147
+ selectedSkillIds: [...new Set((userMessage.role === "user" ? userMessage.content : []).flatMap(part => {
148
+ const marker = parseAiResourceMarker(typeof part === "string" ? part : part.type === "text" ? part.text : "");
149
+ return marker?.ref.type === "core.ai.skill" ? [marker.ref.id] : [];
150
+ }))],
133
151
  ...(input.assistantChat ? { assistantChat: true } : {}),
134
152
  input: canonicalInput,
135
153
  chatId: input.chatId,
@@ -146,7 +164,7 @@ export const submitAiChatTurn = async (input: SubmitAiChatTurnInput): Promise<{
146
164
  toolApprovalContext: input.toolApprovalContext,
147
165
  };
148
166
 
149
- const submitted = await aiConversations.submitChatTurn({
167
+ return {
150
168
  conversationId: input.conversationId,
151
169
  modelProfileId: resolved.profile.id,
152
170
  runConfig,
@@ -156,8 +174,11 @@ export const submitAiChatTurn = async (input: SubmitAiChatTurnInput): Promise<{
156
174
  expectedProjectId: input.expectedProjectId,
157
175
  resources: input.resources,
158
176
  retrySourceTurnId: input.retrySourceTurnId,
159
- });
177
+ };
178
+ };
160
179
 
180
+ export const submitAiChatTurn = async (input: SubmitAiChatTurnInput): Promise<{ turn: AiTurn; message: AiStoredMessage }> => {
181
+ const submitted = await aiConversations.submitChatTurn(await prepareAiChatTurn(input));
161
182
  await enqueueAiTurn({ conversationId: input.conversationId, turnId: submitted.turn.id });
162
183
  return submitted;
163
184
  };
@@ -242,7 +263,8 @@ export const abortAiTurn = async (input: { conversationId: string; turnId: strin
242
263
 
243
264
  if (request.ownerless) {
244
265
  const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
245
- if (finalized === "completed") {
266
+ const config = await aiConversations.getTurnRunConfig(input);
267
+ if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
246
268
  const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
247
269
  const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
248
270
  await publishAiWireEvent({
@@ -343,9 +365,7 @@ export const submitAiTurnAction = async (input: {
343
365
  return { ok: false, status: 409, message: "AI action was already resolved with a different response." };
344
366
  }
345
367
  }
346
- await aiToolAudit
347
- .noteToolCompleted({ turnId: input.turnId, callId: input.callId, result: input.action.result, isError: false })
348
- .catch(() => undefined);
368
+ await aiToolAudit.noteToolCompleted({ turnId: input.turnId, callId: input.callId, isError: false }).catch(() => undefined);
349
369
  }
350
370
 
351
371
  await enqueueAiTurn({ conversationId: input.conversationId, turnId: input.turnId });
@@ -362,6 +382,8 @@ const runClaimedTurn = async (
362
382
  leaseOwner: string,
363
383
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
364
384
  ): Promise<void> => {
385
+ const timeoutMinutes = await coreSettings.get<number>("ai.turn_timeout_minutes");
386
+ const runBudgetMs = Math.max(0, Number(timeoutMinutes ?? 30)) * 60_000;
365
387
  const claim =
366
388
  (await aiConversations.claimTurn({
367
389
  ...job,
@@ -369,7 +391,7 @@ const runClaimedTurn = async (
369
391
  leaseMs: AI_TURN_LEASE_MS,
370
392
  from: "queue",
371
393
  maxAttempts: AI_TURN_MAX_ATTEMPTS,
372
- runBudgetMs: AI_TURN_RUN_BUDGET_MS,
394
+ runBudgetMs,
373
395
  })) ??
374
396
  (await aiConversations.claimTurn({
375
397
  ...job,
@@ -377,7 +399,7 @@ const runClaimedTurn = async (
377
399
  leaseMs: AI_TURN_LEASE_MS,
378
400
  from: "waiting",
379
401
  maxAttempts: AI_TURN_MAX_ATTEMPTS,
380
- runBudgetMs: AI_TURN_RUN_BUDGET_MS,
402
+ runBudgetMs,
381
403
  }));
382
404
 
383
405
  if (!claim) return; // Already owned, done, cancelled, or attempt-capped.
@@ -392,12 +414,22 @@ const runClaimedTurn = async (
392
414
  };
393
415
 
394
416
  const processMessage = async (
395
- message: QueueMessage<AiTurnJob>,
417
+ message: Pick<QueueMessage<AiTurnJob>, "messageId" | "data" | "heartbeat">,
396
418
  signal: AbortSignal,
397
419
  onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
398
420
  ): Promise<void> => {
399
421
  const leaseOwner = `${AI_WORKER_ID}:${message.messageId}:${crypto.randomUUID()}`;
400
- const touch = setInterval(() => void message.heartbeat().catch(() => undefined), AI_TURN_HEARTBEAT_MS);
422
+ let heartbeatFailed = false;
423
+ const touch = setInterval(() => {
424
+ void message.heartbeat().then(() => { heartbeatFailed = false; }).catch(() => {
425
+ if (!heartbeatFailed && !signal.aborted) {
426
+ log.warn("AI queue heartbeat failed", {
427
+ code: "queue_heartbeat_failed", conversationId: message.data.conversationId, turnId: message.data.turnId,
428
+ });
429
+ }
430
+ heartbeatFailed = true;
431
+ });
432
+ }, AI_TURN_HEARTBEAT_MS);
401
433
  if (typeof touch === "object" && "unref" in touch) touch.unref();
402
434
  try {
403
435
  await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized);
@@ -409,10 +441,13 @@ const processMessage = async (
409
441
  });
410
442
  } finally {
411
443
  clearInterval(touch);
444
+ if (!signal.aborted) wakeAiMessageQueue(message.data.conversationId);
412
445
  }
413
446
  };
414
447
 
415
448
  const publishSweepFinished = async (turn: AiTurnFinalizedAction & { error?: string }, status: "failed" | "aborted"): Promise<void> => {
449
+ const config = await aiConversations.getTurnRunConfig(turn);
450
+ if (config?.kind === "chat" && config.background) return;
416
451
  await publishAiWireEvent({
417
452
  v: 1,
418
453
  conversationId: turn.conversationId,
@@ -422,13 +457,15 @@ const publishSweepFinished = async (turn: AiTurnFinalizedAction & { error?: stri
422
457
  type: "turn_finished",
423
458
  status,
424
459
  error: status === "failed" ? (turn.error ?? "AI turn failed.") : null,
425
- }).catch(() => undefined);
460
+ }).catch(() => log.warn("AI completion notification failed", {code:"completion_publish_failed",conversationId:turn.conversationId,turnId:turn.turnId}));
426
461
  };
427
462
 
428
463
  export const sweepAiRuntime = async (onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>): Promise<void> => {
464
+ await aiQuotas.prune().catch(() => log.warn("Quota retention failed", { code: "quota_retention_failed" }));
465
+ await drainQueuedMessages(enqueueAiTurn);
429
466
  const sweep = await aiConversations.sweepTurns({ maxAttempts: AI_TURN_MAX_ATTEMPTS });
430
467
  await Promise.all([
431
- ...sweep.requeued.map((job) => enqueueAiTurn(job).catch(() => undefined)),
468
+ ...sweep.requeued.map((job) => enqueueAiTurn(job).catch(() => log.warn("AI recovery enqueue failed", {code:"recovery_enqueue_failed",...job}))),
432
469
  ...sweep.failed.map((turn) => publishSweepFinished(turn, "failed")),
433
470
  ...sweep.aborted.map((turn) => publishSweepFinished(turn, "aborted")),
434
471
  ]);
@@ -508,11 +545,13 @@ export const startAiRuntime = (
508
545
  const sweepTimer = setInterval(runSweep, AI_SWEEP_INTERVAL_MS);
509
546
  if (typeof sweepTimer === "object" && "unref" in sweepTimer) sweepTimer.unref();
510
547
 
548
+ const stopDictations = startAiDictationRuntime();
511
549
  const state: AiRuntimeState = {
512
550
  listeners,
513
551
  stop: () => {
514
552
  if (running !== state) return;
515
553
  controller.abort();
554
+ stopDictations();
516
555
  clearInterval(sweepTimer);
517
556
  listeners.clear();
518
557
  running = null;
@@ -541,4 +580,4 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
541
580
  };
542
581
  };
543
582
 
544
- export const __aiRuntimeTest = { actionMatchesResolvedEvent };
583
+ export const __aiRuntimeTest = { actionMatchesResolvedEvent, processMessage, publishSweepFinished };