@k2b/cloud 0.6.0 → 0.8.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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/store.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
2
2
  import type { Usage } from "@k2b/nessi/ai";
3
- import { sql } from "bun";
3
+ import { type SQL, sql } from "bun";
4
4
  import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
5
  import { logger } from "../services/logging";
6
6
  import { toPgTextArray } from "../services/postgres";
7
7
  import type { AiTurnBlock } from "./protocol";
8
8
  import { withAiShortId, withAiShortIdForDb } from "./short-id";
9
+ import { parseAiTodoPlan } from "./todo-contracts";
9
10
  import type {
10
11
  AiConversation,
11
12
  AiConversationDraft,
@@ -89,6 +90,7 @@ const isSearchCapabilityError = (error: unknown): boolean =>
89
90
  typeof error === "object" && error !== null && "code" in error && SEARCH_CAPABILITY_ERROR_CODES.has(String(error.code));
90
91
 
91
92
  type ConversationRow = {
93
+ activity?: AiConversation["activity"];
92
94
  id: string;
93
95
  short_id: string;
94
96
  title: string;
@@ -98,8 +100,14 @@ type ConversationRow = {
98
100
  keywords: string[] | null;
99
101
  pinned_at: Date | string | null;
100
102
  archived_at: Date | string | null;
103
+ done: boolean | null;
104
+ last_used_at: Date | string;
105
+ is_done?: boolean;
106
+ has_active_schedule?: boolean;
107
+ background_received_at?: Date | string | null;
101
108
  last_viewed_at: Date | string | null;
102
109
  latest_turn_status?: AiTurnStatus | null;
110
+ latest_browser_pending?: boolean;
103
111
  latest_turn_error?: string | null;
104
112
  latest_turn_completed_at?: Date | string | null;
105
113
  enrich_fail_count: number | null;
@@ -157,6 +165,9 @@ type MessageRow = {
157
165
  };
158
166
 
159
167
  type TurnRow = {
168
+ deadline?: Date | string | null;
169
+ run_budget_ms?: number | null;
170
+ cancel_requested_at?: Date | string | null;
160
171
  id: string;
161
172
  short_id: string;
162
173
  conversation_id: string;
@@ -360,7 +371,7 @@ const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage =>
360
371
  deliveredAt: row.delivered_at ? iso(row.delivered_at) : null,
361
372
  });
362
373
 
363
- const interChatMessageSelect = sql`
374
+ const interChatMessageSelect = () => sql`
364
375
  SELECT message.id, message.short_id, message.source_conversation_id,
365
376
  source.short_id AS source_chat_id, source.title AS source_title,
366
377
  message.source_turn_id, source_turn.short_id AS source_turn_short_id, message.source_call_id,
@@ -470,16 +481,55 @@ const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | u
470
481
 
471
482
  const fieldSource = (value: string | null): AiConversation["titleSource"] => (value === "auto" || value === "user" ? value : "default");
472
483
 
473
- const conversationRunStatus = (status: AiTurnStatus | null | undefined): AiConversation["runStatus"] => {
484
+ // Pins stay active; otherwise explicit choices win and automatic completion never hides pending work.
485
+ const effectiveDone = () => sql`(conversation.pinned_at IS NULL AND COALESCE(conversation.done,
486
+ conversation.last_used_at <= now() - interval '7 days'
487
+ AND NOT EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active')
488
+ AND NOT EXISTS (SELECT 1 FROM ai.turns active_turn
489
+ WHERE active_turn.conversation_id = conversation.id
490
+ AND NOT COALESCE(active_turn.run_config ? 'background', false)
491
+ AND active_turn.status IN ('queued', 'running', 'waiting_for_action'))
492
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages pending WHERE pending.conversation_id = conversation.id AND pending.status IN ('pending','failed'))))`;
493
+
494
+ const browserWorkPending = () => sql`(
495
+ latest.status = 'waiting_for_action'
496
+ AND EXISTS (
497
+ SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
498
+ WHERE block->>'kind' = 'tool' AND block->>'status' = 'awaiting_client' AND block->>'frontendMode' = 'client'
499
+ )
500
+ AND NOT EXISTS (
501
+ SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
502
+ WHERE block->>'kind' = 'tool' AND (
503
+ block->>'status' = 'awaiting_approval'
504
+ OR (block->>'status' = 'awaiting_client' AND COALESCE(block->>'frontendMode', '') <> 'client')
505
+ )
506
+ )
507
+ )`;
508
+
509
+ const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserPending = false): AiConversation["runStatus"] => {
474
510
  if (status === "queued") return "queued";
475
511
  if (status === "running") return "running";
476
- if (status === "waiting_for_action") return "needs_attention";
512
+ if (status === "waiting_for_action") return browserPending ? "waiting_for_browser" : "needs_attention";
477
513
  if (status === "failed") return "failed";
478
514
  return "idle";
479
515
  };
480
516
 
517
+ // Bun SQL may return historical JSON payloads as string-encoded JSON.
518
+ const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
519
+ const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
520
+
521
+ // A scheduled result arriving during an interactive turn becomes context on the next turn.
522
+ const visibleToTurn = (turnId?: string) => sql`(
523
+ ${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
524
+ SELECT input_message.seq FROM ai.messages input_message
525
+ WHERE input_message.loop_id = ${turnId ?? null} AND input_message.role = 'user'
526
+ )
527
+ )`;
528
+
481
529
  const rowToConversation = (row: ConversationRow): AiConversation => ({
530
+ ...(row.activity ? { activity: row.activity } : {}),
482
531
  id: row.id,
532
+ hasActiveSchedule: Boolean(row.has_active_schedule),
483
533
  shortId: row.short_id,
484
534
  title: row.title,
485
535
  titleSource: fieldSource(row.title_source),
@@ -488,12 +538,25 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
488
538
  keywords: row.keywords ?? [],
489
539
  pinnedAt: row.pinned_at ? iso(row.pinned_at) : null,
490
540
  archivedAt: row.archived_at ? iso(row.archived_at) : null,
491
- runStatus: conversationRunStatus(row.latest_turn_status),
541
+ done: row.done,
542
+ isDone: row.pinned_at
543
+ ? false
544
+ : (row.is_done ??
545
+ row.done ??
546
+ (new Date(row.last_used_at).getTime() <= Date.now() - 7 * 86400000 &&
547
+ !row.has_active_schedule &&
548
+ !["queued", "running", "waiting_for_action"].includes(row.latest_turn_status ?? ""))),
549
+ lastUsedAt: iso(row.last_used_at),
550
+ runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
492
551
  runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
493
552
  unreadCompletion:
494
- row.latest_turn_status === "completed" &&
495
- Boolean(row.latest_turn_completed_at) &&
496
- (!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime()),
553
+ Boolean(
554
+ row.background_received_at &&
555
+ (!row.last_viewed_at || new Date(row.background_received_at).getTime() > new Date(row.last_viewed_at).getTime()),
556
+ ) ||
557
+ (row.latest_turn_status === "completed" &&
558
+ Boolean(row.latest_turn_completed_at) &&
559
+ (!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime())),
497
560
  projectId: row.project_id,
498
561
  draft: {
499
562
  content: parseJsonValue<AiConversationDraft["content"]>(row.draft_content ?? []),
@@ -566,6 +629,9 @@ const rowToMessage = (row: MessageRow): AiStoredMessage => {
566
629
  };
567
630
 
568
631
  const rowToTurn = (row: TurnRow): AiTurn => ({
632
+ deadline: row.deadline ? iso(row.deadline) : null,
633
+ runBudgetMs: row.run_budget_ms ?? null,
634
+ cancelRequestedAt: row.cancel_requested_at ? iso(row.cancel_requested_at) : null,
569
635
  id: row.id,
570
636
  shortId: row.short_id,
571
637
  conversationId: row.conversation_id,
@@ -591,7 +657,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
591
657
  conversationId: row.conversation_id,
592
658
  callId: row.call_id,
593
659
  name: row.tool_name,
594
- args: parseJsonValue(row.args),
660
+ args: row.args,
595
661
  mode: row.frontend_mode ?? "client",
596
662
  }
597
663
  : {
@@ -600,7 +666,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
600
666
  conversationId: row.conversation_id,
601
667
  callId: row.call_id,
602
668
  name: row.tool_name,
603
- args: parseJsonValue(row.args),
669
+ args: row.args,
604
670
  message: row.message ?? undefined,
605
671
  review: parseCapabilityActionReview(row.review),
606
672
  allowAlways: row.allow_always,
@@ -613,7 +679,7 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
613
679
  kind: row.kind,
614
680
  status: row.status,
615
681
  name: row.tool_name,
616
- args: parseJsonValue(row.args),
682
+ args: row.args,
617
683
  message: row.message ?? undefined,
618
684
  review: parseCapabilityActionReview(row.review),
619
685
  approvalScope: row.approval_scope,
@@ -649,14 +715,17 @@ const loadConversationSummary = async (input: {
649
715
  const rows = await sql<ConversationRow[]>`
650
716
  SELECT
651
717
  conversation.*,
718
+ ${effectiveDone()} AS is_done,
719
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
652
720
  latest.status AS latest_turn_status,
721
+ ${browserWorkPending()} AS latest_browser_pending,
653
722
  latest.error AS latest_turn_error,
654
723
  latest.completed_at AS latest_turn_completed_at
655
724
  FROM ai.conversations conversation
656
725
  LEFT JOIN LATERAL (
657
- SELECT status, error, completed_at
726
+ SELECT status, error, completed_at, live_blocks
658
727
  FROM ai.turns
659
- WHERE conversation_id = conversation.id
728
+ WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
660
729
  ORDER BY created_at DESC, id DESC
661
730
  LIMIT 1
662
731
  ) latest ON TRUE
@@ -666,7 +735,15 @@ const loadConversationSummary = async (input: {
666
735
  AND (${Boolean(input.archived)}::boolean = (conversation.archived_at IS NOT NULL))
667
736
  LIMIT 1
668
737
  `;
669
- return rows[0] ? rowToConversation(rows[0]) : null;
738
+ if (!rows[0]) return null;
739
+ const plans = await sql<{ seq: number; plan: unknown }[]>`
740
+ SELECT seq, CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
741
+ FROM ai.messages WHERE conversation_id = ${rows[0].id}
742
+ AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
743
+ ORDER BY seq DESC, id DESC LIMIT 1
744
+ `;
745
+ const plan = parseAiTodoPlan(plans[0]?.plan);
746
+ return { ...rowToConversation(rows[0]), ...(plan ? { todoPlan: { seq: Number(plans[0]!.seq), todos: plan.todos } } : {}) };
670
747
  };
671
748
 
672
749
  const firstText = (message: Message): string => {
@@ -745,14 +822,14 @@ const insertMessageLocked = async (
745
822
  ${seq},
746
823
  ${input.kind ?? "message"},
747
824
  ${input.message.role},
748
- ${JSON.stringify(input.message)}::jsonb,
825
+ ${JSON.stringify(input.message)}::text::jsonb,
749
826
  ${messageSearchText(input.message)},
750
827
  ${input.loopId ?? null},
751
828
  ${input.modelProfileId ?? null},
752
829
  ${providerModel},
753
- ${usage ? JSON.stringify(usage) : null}::jsonb,
830
+ ${usage ? JSON.stringify(usage) : null}::text::jsonb,
754
831
  ${stopReason},
755
- ${input.meta ? JSON.stringify(input.meta) : null}::jsonb
832
+ ${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
756
833
  )
757
834
  RETURNING *
758
835
  `,
@@ -776,6 +853,7 @@ const insertMessageLocked = async (
776
853
 
777
854
  /** Append a turn-owned (assistant/tool_result/summary) message, guarded by lease ownership in one statement. */
778
855
  const appendTurnOwnedMessage = async (input: {
856
+ background?: boolean;
779
857
  conversationId: string;
780
858
  turnId: string;
781
859
  leaseOwner: string;
@@ -785,12 +863,15 @@ const appendTurnOwnedMessage = async (input: {
785
863
  loopId: string | null;
786
864
  modelProfileId?: string | null;
787
865
  meta?: AiStoredMessage["meta"];
788
- }): Promise<boolean> => {
789
- const { usage, providerModel, stopReason } = messageColumns(input.message);
790
- const rows = await withAiShortId(
791
- "idx_ai_messages_conversation_short_id",
792
- (shortId) => sql<{ id: string }[]>`
793
- INSERT INTO ai.messages (
866
+ }): Promise<AiStoredMessage | null> => {
867
+ return sql.begin(async (tx) => {
868
+ if (!input.background) await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid FOR UPDATE`;
869
+ const { usage, providerModel, stopReason } = messageColumns(input.message);
870
+ const rows = await withAiShortIdForDb(
871
+ tx,
872
+ "idx_ai_messages_conversation_short_id",
873
+ (attempt, shortId) => attempt<MessageRow[]>`
874
+ INSERT INTO ${sql(input.background ? "ai.task_messages" : "ai.messages")} (
794
875
  short_id,
795
876
  conversation_id,
796
877
  seq,
@@ -808,18 +889,18 @@ const appendTurnOwnedMessage = async (input: {
808
889
  ${shortId},
809
890
  ${input.conversationId},
810
891
  CASE
811
- WHEN ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
812
- ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0)
892
+ WHEN NOT ${Boolean(input.background)} AND ${input.kind === "summary"} AND ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
893
+ ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ${sql(input.background ? "ai.task_messages" : "ai.messages")} WHERE conversation_id = ${input.conversationId} AND seq > 0 AND (NOT ${Boolean(input.background)} OR loop_id = ${input.turnId}))
813
894
  END,
814
895
  ${input.kind ?? "message"},
815
896
  ${input.message.role},
816
- ${JSON.stringify(input.message)}::jsonb,
897
+ ${JSON.stringify(input.message)}::text::jsonb,
817
898
  ${input.loopId},
818
899
  ${input.modelProfileId ?? null},
819
900
  ${providerModel},
820
- ${usage ? JSON.stringify(usage) : null}::jsonb,
901
+ ${usage ? JSON.stringify(usage) : null}::text::jsonb,
821
902
  ${stopReason},
822
- ${input.meta ? JSON.stringify(input.meta) : null}::jsonb
903
+ ${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
823
904
  WHERE EXISTS (
824
905
  SELECT 1
825
906
  FROM ai.turns
@@ -828,14 +909,15 @@ const appendTurnOwnedMessage = async (input: {
828
909
  AND status IN ('running', 'waiting_for_action')
829
910
  AND lease_owner = ${input.leaseOwner}
830
911
  )
831
- RETURNING id
912
+ RETURNING *
832
913
  `,
833
- );
834
- if (rows[0]) {
835
- await sql`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
836
- return true;
837
- }
838
- return false;
914
+ );
915
+ if (rows[0]) {
916
+ if (!input.background) await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
917
+ return rowToMessage(rows[0]);
918
+ }
919
+ return null;
920
+ });
839
921
  };
840
922
 
841
923
  const toolMessageMeta = (
@@ -857,6 +939,69 @@ const toolMessageMeta = (
857
939
  return Object.keys(toolPresentations).length > 0 ? { toolPresentations } : null;
858
940
  };
859
941
 
942
+ export const saveAiDraftInTransaction = async (
943
+ tx: SQL,
944
+ input: Parameters<AiConversationService["saveDraft"]>[0],
945
+ ): ReturnType<AiConversationService["saveDraft"]> => {
946
+ const [conversation] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]>`
947
+ SELECT draft_content, draft_revision, draft_updated_at
948
+ FROM ai.conversations
949
+ WHERE id = ${input.conversationId}::uuid
950
+ AND created_by_user_id = ${input.ownerUserId}::uuid
951
+ AND archived_at IS NULL
952
+ FOR UPDATE
953
+ `;
954
+ if (!conversation) return { ok: false as const, reason: "not_found" as const };
955
+ if (Number(conversation.draft_revision) !== input.expectedRevision) {
956
+ return { ok: false as const, reason: "conflict" as const };
957
+ }
958
+ const [sameDraft] = await tx<{ same: boolean }[]>`
959
+ SELECT ${JSON.stringify(input.content)}::text::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::text::jsonb AS same
960
+ `;
961
+ if (sameDraft?.same) {
962
+ return {
963
+ ok: true as const,
964
+ draft: {
965
+ content: input.content,
966
+ revision: Number(conversation.draft_revision),
967
+ updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
968
+ },
969
+ };
970
+ }
971
+
972
+ for (const part of input.content) {
973
+ if (part.type !== "file") continue;
974
+ const [file] = await tx<{ exists: boolean }[]>`
975
+ SELECT EXISTS (
976
+ SELECT 1 FROM ai.files
977
+ WHERE conversation_id = ${input.conversationId}::uuid
978
+ AND path = ${part.path}
979
+ AND media_type = ${part.mediaType}
980
+ AND size = ${part.size}
981
+ AND version = ${part.version}
982
+ ) AS exists
983
+ `;
984
+ if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
985
+ }
986
+
987
+ const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
988
+ UPDATE ai.conversations
989
+ SET draft_content = ${JSON.stringify(input.content)}::text::jsonb,
990
+ draft_revision = draft_revision + 1,
991
+ draft_updated_at = now()
992
+ WHERE id = ${input.conversationId}::uuid
993
+ RETURNING draft_content, draft_revision, draft_updated_at
994
+ `;
995
+ return {
996
+ ok: true as const,
997
+ draft: {
998
+ content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
999
+ revision: Number(saved!.draft_revision),
1000
+ updatedAt: iso(saved!.draft_updated_at),
1001
+ },
1002
+ };
1003
+ };
1004
+
860
1005
  export const aiConversations: AiConversationService = {
861
1006
  createConversation: async (input) => {
862
1007
  const rows = await withAiShortId(
@@ -880,7 +1025,7 @@ export const aiConversations: AiConversationService = {
880
1025
  ${input.title?.trim() || "New chat"},
881
1026
  ${input.description?.trim() ?? ""},
882
1027
  ${input.projectId ?? null}::uuid,
883
- ${JSON.stringify(input.draft ?? [])}::jsonb,
1028
+ ${JSON.stringify(input.draft ?? [])}::text::jsonb,
884
1029
  ${input.draft?.length ? 1 : 0},
885
1030
  ${toPgTextArray(input.preloadTools ?? [])}::text[],
886
1031
  ${input.draft?.length ? new Date() : null},
@@ -920,11 +1065,12 @@ export const aiConversations: AiConversationService = {
920
1065
  await tx`
921
1066
  INSERT INTO ai.messages (
922
1067
  short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
923
- model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
1068
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
924
1069
  )
925
1070
  SELECT
926
1071
  short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
927
- model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
1072
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
1073
+ CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
928
1074
  FROM ai.messages
929
1075
  WHERE conversation_id = ${input.sourceConversationId}::uuid
930
1076
  AND compacted_at IS NULL
@@ -932,8 +1078,8 @@ export const aiConversations: AiConversationService = {
932
1078
  ORDER BY seq ASC
933
1079
  `;
934
1080
  await tx`
935
- INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
936
- SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, updated_at
1081
+ INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
1082
+ SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
937
1083
  FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
938
1084
  `;
939
1085
  return rowToConversation(target);
@@ -950,23 +1096,29 @@ export const aiConversations: AiConversationService = {
950
1096
  const rows = await withConversationSearchBackend(query, async (backend) => {
951
1097
  const order = query
952
1098
  ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
953
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1099
+ : input.done === true
1100
+ ? sql`conversation.last_used_at DESC, conversation.id DESC`
1101
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
954
1102
  return sql<ConversationRow[]>`
955
1103
  SELECT
956
1104
  conversation.*,
1105
+ ${effectiveDone()} AS is_done,
1106
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
957
1107
  latest.status AS latest_turn_status,
1108
+ ${browserWorkPending()} AS latest_browser_pending,
958
1109
  latest.error AS latest_turn_error,
959
1110
  latest.completed_at AS latest_turn_completed_at
960
1111
  FROM ai.conversations conversation
961
1112
  LEFT JOIN LATERAL (
962
- SELECT status, error, completed_at
1113
+ SELECT status, error, completed_at, live_blocks
963
1114
  FROM ai.turns
964
- WHERE conversation_id = conversation.id
1115
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
965
1116
  ORDER BY created_at DESC, id DESC
966
1117
  LIMIT 1
967
1118
  ) latest ON TRUE
968
1119
  WHERE conversation.created_by_user_id = ${input.ownerUserId}
969
1120
  AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1121
+ AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
970
1122
  AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
971
1123
  AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
972
1124
  AND (${refs.length === 0}::boolean OR NOT EXISTS (
@@ -993,10 +1145,12 @@ export const aiConversations: AiConversationService = {
993
1145
  OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
994
1146
  ))
995
1147
  AND (${status}::text IS NULL
996
- OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
997
- OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1148
+ OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
1149
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
998
1150
  OR (${status} = 'failed' AND latest.status = 'failed')
999
- OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1151
+ OR (${status} = 'unread' AND (
1152
+ (latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
1153
+ OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
1000
1154
  ORDER BY ${order}
1001
1155
  LIMIT ${limit}
1002
1156
  `;
@@ -1005,35 +1159,33 @@ export const aiConversations: AiConversationService = {
1005
1159
  },
1006
1160
 
1007
1161
  listSidebarConversations: async (input) => {
1008
- const unassignedLimit = Math.min(Math.max(input.unassignedLimit ?? 15, 1), 50);
1009
- const perProjectLimit = Math.min(Math.max(input.perProjectLimit ?? 10, 1), 50);
1010
1162
  const rows = await sql<ConversationRow[]>`
1011
- WITH ranked AS (
1012
- SELECT
1013
- conversation.*,
1014
- latest.status AS latest_turn_status,
1015
- latest.error AS latest_turn_error,
1016
- latest.completed_at AS latest_turn_completed_at,
1017
- row_number() OVER (
1018
- PARTITION BY conversation.project_id
1019
- ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id
1020
- ) AS sidebar_rank
1021
- FROM ai.conversations conversation
1022
- LEFT JOIN LATERAL (
1023
- SELECT status, error, completed_at
1024
- FROM ai.turns
1025
- WHERE conversation_id = conversation.id
1026
- ORDER BY created_at DESC, id DESC
1027
- LIMIT 1
1028
- ) latest ON TRUE
1029
- WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
1030
- AND conversation.archived_at IS NULL
1031
- )
1032
- SELECT *
1033
- FROM ranked
1034
- WHERE (project_id IS NULL AND sidebar_rank <= ${unassignedLimit})
1035
- OR (project_id IS NOT NULL AND sidebar_rank <= ${perProjectLimit})
1036
- ORDER BY pinned_at DESC NULLS LAST, updated_at DESC, created_at DESC, id
1163
+ SELECT conversation.*, ${effectiveDone()} AS is_done,
1164
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
1165
+ latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
1166
+ latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
1167
+ jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
1168
+ 'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
1169
+ FROM ai.conversations conversation
1170
+ LEFT JOIN LATERAL (
1171
+ SELECT status, error, completed_at, live_blocks FROM ai.turns
1172
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
1173
+ ) latest ON TRUE
1174
+ LEFT JOIN LATERAL (
1175
+ SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
1176
+ FROM ai.messages WHERE conversation_id = conversation.id
1177
+ AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
1178
+ ORDER BY seq DESC, id DESC LIMIT 1
1179
+ ) working ON TRUE
1180
+ LEFT JOIN LATERAL (
1181
+ SELECT count(*) FILTER (WHERE task->>'status' = 'completed')::int AS completed,
1182
+ count(*) FILTER (WHERE task->>'status' <> 'cancelled')::int AS total,
1183
+ max(task->>'content') FILTER (WHERE task->>'status' = 'in_progress') AS step
1184
+ FROM jsonb_array_elements(CASE WHEN jsonb_typeof(working.plan->'todos') = 'array' THEN working.plan->'todos' ELSE '[]'::jsonb END) task
1185
+ ) progress ON TRUE
1186
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
1187
+ AND conversation.archived_at IS NULL AND NOT ${effectiveDone()}
1188
+ ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.last_used_at DESC, conversation.id
1037
1189
  `;
1038
1190
  return rows.map(rowToConversation);
1039
1191
  },
@@ -1047,23 +1199,29 @@ export const aiConversations: AiConversationService = {
1047
1199
  const rows = await withConversationSearchBackend(query, async (backend) => {
1048
1200
  const order = query
1049
1201
  ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1050
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1202
+ : input.done === true
1203
+ ? sql`conversation.last_used_at DESC, conversation.id DESC`
1204
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1051
1205
  return sql<ConversationRow[]>`
1052
1206
  SELECT
1053
1207
  conversation.*,
1208
+ ${effectiveDone()} AS is_done,
1209
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
1054
1210
  latest.status AS latest_turn_status,
1211
+ ${browserWorkPending()} AS latest_browser_pending,
1055
1212
  latest.error AS latest_turn_error,
1056
1213
  latest.completed_at AS latest_turn_completed_at
1057
1214
  FROM ai.conversations conversation
1058
1215
  LEFT JOIN LATERAL (
1059
- SELECT status, error, completed_at
1216
+ SELECT status, error, completed_at, live_blocks
1060
1217
  FROM ai.turns
1061
- WHERE conversation_id = conversation.id
1218
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1062
1219
  ORDER BY created_at DESC, id DESC
1063
1220
  LIMIT 1
1064
1221
  ) latest ON TRUE
1065
1222
  WHERE conversation.created_by_user_id = ${input.ownerUserId}
1066
1223
  AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1224
+ AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
1067
1225
  AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
1068
1226
  AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
1069
1227
  AND (${pattern}::text IS NULL
@@ -1079,10 +1237,12 @@ export const aiConversations: AiConversationService = {
1079
1237
  OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
1080
1238
  ))
1081
1239
  AND (${status}::text IS NULL
1082
- OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
1083
- OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1240
+ OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
1241
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
1084
1242
  OR (${status} = 'failed' AND latest.status = 'failed')
1085
- OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1243
+ OR (${status} = 'unread' AND (
1244
+ (latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
1245
+ OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
1086
1246
  ORDER BY ${order}
1087
1247
  LIMIT ${perPage}
1088
1248
  OFFSET ${offset}
@@ -1092,14 +1252,15 @@ export const aiConversations: AiConversationService = {
1092
1252
  SELECT COUNT(*) AS total
1093
1253
  FROM ai.conversations conversation
1094
1254
  LEFT JOIN LATERAL (
1095
- SELECT status, completed_at
1255
+ SELECT status, completed_at, live_blocks
1096
1256
  FROM ai.turns
1097
- WHERE conversation_id = conversation.id
1257
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1098
1258
  ORDER BY created_at DESC, id DESC
1099
1259
  LIMIT 1
1100
1260
  ) latest ON TRUE
1101
1261
  WHERE conversation.created_by_user_id = ${input.ownerUserId}
1102
1262
  AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1263
+ AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
1103
1264
  AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
1104
1265
  AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
1105
1266
  AND (${pattern}::text IS NULL
@@ -1115,10 +1276,12 @@ export const aiConversations: AiConversationService = {
1115
1276
  OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
1116
1277
  ))
1117
1278
  AND (${status}::text IS NULL
1118
- OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
1119
- OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1279
+ OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
1280
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
1120
1281
  OR (${status} = 'failed' AND latest.status = 'failed')
1121
- OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1282
+ OR (${status} = 'unread' AND (
1283
+ (latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
1284
+ OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
1122
1285
  `;
1123
1286
  const total = Number(countRows[0]?.total ?? 0);
1124
1287
  return {
@@ -1138,68 +1301,7 @@ export const aiConversations: AiConversationService = {
1138
1301
  return loadConversationSummary(input);
1139
1302
  },
1140
1303
 
1141
- saveDraft: async (input) =>
1142
- sql.begin(async (tx) => {
1143
- const [conversation] = await tx<
1144
- { draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]
1145
- >`
1146
- SELECT draft_content, draft_revision, draft_updated_at
1147
- FROM ai.conversations
1148
- WHERE id = ${input.conversationId}::uuid
1149
- AND created_by_user_id = ${input.ownerUserId}::uuid
1150
- AND archived_at IS NULL
1151
- FOR UPDATE
1152
- `;
1153
- if (!conversation) return { ok: false as const, reason: "not_found" as const };
1154
- if (Number(conversation.draft_revision) !== input.expectedRevision) {
1155
- return { ok: false as const, reason: "conflict" as const };
1156
- }
1157
- const [sameDraft] = await tx<{ same: boolean }[]>`
1158
- SELECT ${JSON.stringify(input.content)}::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::jsonb AS same
1159
- `;
1160
- if (sameDraft?.same) {
1161
- return {
1162
- ok: true as const,
1163
- draft: {
1164
- content: input.content,
1165
- revision: Number(conversation.draft_revision),
1166
- updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
1167
- },
1168
- };
1169
- }
1170
-
1171
- for (const part of input.content) {
1172
- if (part.type !== "file") continue;
1173
- const [file] = await tx<{ exists: boolean }[]>`
1174
- SELECT EXISTS (
1175
- SELECT 1 FROM ai.files
1176
- WHERE conversation_id = ${input.conversationId}::uuid
1177
- AND path = ${part.path}
1178
- AND media_type = ${part.mediaType}
1179
- AND size = ${part.size}
1180
- AND version = ${part.version}
1181
- ) AS exists
1182
- `;
1183
- if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
1184
- }
1185
-
1186
- const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
1187
- UPDATE ai.conversations
1188
- SET draft_content = ${JSON.stringify(input.content)}::jsonb,
1189
- draft_revision = draft_revision + 1,
1190
- draft_updated_at = now()
1191
- WHERE id = ${input.conversationId}::uuid
1192
- RETURNING draft_content, draft_revision, draft_updated_at
1193
- `;
1194
- return {
1195
- ok: true as const,
1196
- draft: {
1197
- content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
1198
- revision: Number(saved!.draft_revision),
1199
- updatedAt: iso(saved!.draft_updated_at),
1200
- },
1201
- };
1202
- }),
1304
+ saveDraft: async (input) => sql.begin((tx) => saveAiDraftInTransaction(tx, input)),
1203
1305
 
1204
1306
  getLoadedTools: async (input) => {
1205
1307
  const rows = await sql<{ loaded_tools: string[] | null }[]>`
@@ -1420,7 +1522,7 @@ export const aiConversations: AiConversationService = {
1420
1522
  },
1421
1523
 
1422
1524
  createInterChatMessage: async (input) => {
1423
- const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect}
1525
+ const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect()}
1424
1526
  WHERE message.idempotency_key = ${input.idempotencyKey}
1425
1527
  AND message.source_conversation_id = ${input.sourceConversationId}::uuid
1426
1528
  AND message.actor_user_id = ${input.actorUserId}::uuid
@@ -1465,21 +1567,22 @@ export const aiConversations: AiConversationService = {
1465
1567
  RETURNING id
1466
1568
  `,
1467
1569
  );
1468
- const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
1570
+ const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
1469
1571
  return { ok: true, message: rowToInterChatMessage(rows[0]!) };
1470
1572
  },
1471
1573
 
1472
1574
  listPendingInterChatMessages: async (input = {}) => {
1473
1575
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 100);
1474
1576
  const rows = await sql<InterChatMessageRow[]>`
1475
- ${interChatMessageSelect}
1577
+ ${interChatMessageSelect()}
1476
1578
  WHERE message.status = 'pending'
1477
1579
  AND (${input.targetConversationId ?? null}::uuid IS NULL OR message.target_conversation_id = ${input.targetConversationId ?? null}::uuid)
1478
1580
  AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR NOT EXISTS (
1479
1581
  SELECT 1
1480
1582
  FROM ai.turns active_turn
1481
1583
  WHERE active_turn.conversation_id = message.target_conversation_id
1482
- AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
1584
+ AND NOT COALESCE(active_turn.run_config ? 'background', false)
1585
+ AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
1483
1586
  ))
1484
1587
  AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR message.id = (
1485
1588
  SELECT oldest.id
@@ -1508,7 +1611,7 @@ export const aiConversations: AiConversationService = {
1508
1611
  deliverInterChatMessage: async (input) => {
1509
1612
  const delivered = await sql.begin(async (tx) => {
1510
1613
  const rows = await tx<InterChatMessageRow[]>`
1511
- ${interChatMessageSelect}
1614
+ ${interChatMessageSelect()}
1512
1615
  WHERE message.id = ${input.messageId}::uuid
1513
1616
  FOR UPDATE OF message
1514
1617
  `;
@@ -1534,6 +1637,7 @@ export const aiConversations: AiConversationService = {
1534
1637
  const [active] = await tx<{ id: string }[]>`
1535
1638
  SELECT id FROM ai.turns
1536
1639
  WHERE conversation_id = ${message.target_conversation_id}::uuid
1640
+ AND NOT COALESCE(run_config ? 'background', false)
1537
1641
  AND status IN ('queued', 'running', 'waiting_for_action')
1538
1642
  LIMIT 1
1539
1643
  `;
@@ -1548,6 +1652,7 @@ export const aiConversations: AiConversationService = {
1548
1652
  RETURNING *
1549
1653
  `,
1550
1654
  );
1655
+ await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${message.target_conversation_id}::uuid`;
1551
1656
  const turn = rowToTurn(turnRows[0]!);
1552
1657
  const targetMeta: NonNullable<AiStoredMessage["meta"]> = {
1553
1658
  agentMessage: {
@@ -1579,7 +1684,7 @@ export const aiConversations: AiConversationService = {
1579
1684
  SET status = 'delivered', target_turn_id = ${turn.id}, target_message_id = ${messageRow.id}, delivered_at = now()
1580
1685
  WHERE id = ${message.id}::uuid
1581
1686
  `;
1582
- const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${message.id}::uuid LIMIT 1`;
1687
+ const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${message.id}::uuid LIMIT 1`;
1583
1688
  return { delivered: true as const, message: rowToInterChatMessage(updated[0]!), turn };
1584
1689
  });
1585
1690
  return delivered;
@@ -1624,12 +1729,16 @@ export const aiConversations: AiConversationService = {
1624
1729
  `;
1625
1730
  if (!conversations[0]) return { ok: false as const, reason: "not_found" as const };
1626
1731
 
1732
+ if (input.onlyUnassigned && conversations[0].project_id) return { ok: false as const, reason: "already_assigned" as const };
1733
+
1627
1734
  const [activity] = await tx<{ exists: boolean }[]>`
1628
1735
  SELECT EXISTS (
1629
1736
  SELECT 1 FROM ai.turns
1630
1737
  WHERE conversation_id = ${input.conversationId}::uuid
1631
1738
  AND status IN ('queued', 'running', 'waiting_for_action')
1632
- ) AS exists
1739
+ ) OR (${Boolean(input.onlyUnassigned)}::boolean AND EXISTS (
1740
+ SELECT 1 FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')
1741
+ )) AS exists
1633
1742
  `;
1634
1743
  if (activity?.exists) return { ok: false as const, reason: "active_turn" as const };
1635
1744
 
@@ -1659,6 +1768,35 @@ export const aiConversations: AiConversationService = {
1659
1768
  return rows[0] ? loadConversationSummary(input) : null;
1660
1769
  },
1661
1770
 
1771
+ setConversationDone: async (input) => {
1772
+ const result = await sql.begin(async (tx) => {
1773
+ // Same lock as turn submission: completion cannot race a new message.
1774
+ const [conversation] = await tx<{ id: string }[]>`
1775
+ SELECT id FROM ai.conversations
1776
+ WHERE id = ${input.conversationId}::uuid AND created_by_user_id = ${input.ownerUserId}::uuid
1777
+ AND archived_at IS NULL FOR UPDATE
1778
+ `;
1779
+ if (!conversation) return { ok: false as const, reason: "not_found" as const };
1780
+ if (input.done) {
1781
+ const [active] = await tx<{ id: string }[]>`
1782
+ SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid
1783
+ AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued', 'running', 'waiting_for_action') LIMIT 1
1784
+ `;
1785
+ const [pending] =
1786
+ await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
1787
+ if (active || pending) return { ok: false as const, reason: "active_turn" as const };
1788
+ }
1789
+ await tx`UPDATE ai.conversations
1790
+ SET done = ${input.done}::boolean,
1791
+ last_used_at = CASE WHEN ${input.done}::boolean IS FALSE THEN now() ELSE last_used_at END
1792
+ WHERE id = ${input.conversationId}::uuid`;
1793
+ return { ok: true as const };
1794
+ });
1795
+ if (!result.ok) return result;
1796
+ const conversation = await loadConversationSummary(input);
1797
+ return conversation ? { ok: true as const, conversation } : { ok: false as const, reason: "not_found" as const };
1798
+ },
1799
+
1662
1800
  archiveConversation: async (input) => {
1663
1801
  const rows = await sql<{ id: string }[]>`
1664
1802
  UPDATE ai.conversations
@@ -1672,6 +1810,7 @@ export const aiConversations: AiConversationService = {
1672
1810
  WHERE conversation_id = ai.conversations.id
1673
1811
  AND status IN ('queued', 'running', 'waiting_for_action')
1674
1812
  )
1813
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
1675
1814
  RETURNING id
1676
1815
  `;
1677
1816
  return Boolean(rows[0]);
@@ -1727,7 +1866,7 @@ export const aiConversations: AiConversationService = {
1727
1866
  AND EXISTS (SELECT 1 FROM ai.messages m WHERE m.conversation_id = c.id)
1728
1867
  AND NOT EXISTS (
1729
1868
  SELECT 1 FROM ai.turns t
1730
- WHERE t.conversation_id = c.id AND t.status IN ('queued', 'running', 'waiting_for_action')
1869
+ WHERE t.conversation_id = c.id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued', 'running', 'waiting_for_action')
1731
1870
  )
1732
1871
  ORDER BY c.updated_at ASC
1733
1872
  LIMIT ${limit}
@@ -2075,7 +2214,7 @@ export const aiConversations: AiConversationService = {
2075
2214
  listTurnMessages: async (input) => {
2076
2215
  const rows = await sql<MessageRow[]>`
2077
2216
  SELECT *
2078
- FROM ai.messages
2217
+ FROM (SELECT * FROM ai.messages UNION ALL SELECT * FROM ai.task_messages) messages
2079
2218
  WHERE conversation_id = ${input.conversationId}
2080
2219
  AND loop_id = ${input.loopId}
2081
2220
  AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
@@ -2139,7 +2278,8 @@ export const aiConversations: AiConversationService = {
2139
2278
  usage,
2140
2279
  stop_reason,
2141
2280
  loop_aggregate,
2142
- loop_done_reason
2281
+ loop_done_reason,
2282
+ meta
2143
2283
  )
2144
2284
  SELECT
2145
2285
  short_id,
@@ -2155,7 +2295,8 @@ export const aiConversations: AiConversationService = {
2155
2295
  usage,
2156
2296
  stop_reason,
2157
2297
  loop_aggregate,
2158
- loop_done_reason
2298
+ loop_done_reason,
2299
+ CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
2159
2300
  FROM ai.messages
2160
2301
  WHERE conversation_id = ${input.sourceConversationId}
2161
2302
  AND compacted_at IS NULL
@@ -2184,13 +2325,15 @@ export const aiConversations: AiConversationService = {
2184
2325
 
2185
2326
  setLatestAssistantLoopAggregate: async (input) => {
2186
2327
  const loopId = input.loopId ?? null;
2328
+ const config = loopId ? await aiConversations.getTurnRunConfig({ conversationId: input.conversationId, turnId: loopId }) : null;
2329
+ const table = config?.kind === "chat" && config.background ? "ai.task_messages" : "ai.messages";
2187
2330
  await sql`
2188
- UPDATE ai.messages
2189
- SET loop_aggregate = ${JSON.stringify(input.aggregate)}::jsonb,
2331
+ UPDATE ${sql(table)}
2332
+ SET loop_aggregate = ${JSON.stringify(input.aggregate)}::text::jsonb,
2190
2333
  loop_done_reason = ${input.doneReason}
2191
2334
  WHERE id = (
2192
2335
  SELECT id
2193
- FROM ai.messages
2336
+ FROM ${sql(table)}
2194
2337
  WHERE conversation_id = ${input.conversationId}
2195
2338
  AND compacted_at IS NULL
2196
2339
  AND kind = 'message'
@@ -2214,9 +2357,18 @@ export const aiConversations: AiConversationService = {
2214
2357
  WHERE conversation_id = ${input.conversationId}
2215
2358
  AND compacted_at IS NULL
2216
2359
  AND seq <= ${checkpointSeq}
2360
+ AND ${visibleToTurn(input.turnId)}
2217
2361
  `;
2218
2362
  if ((rows[0]?.count ?? 0) === 0) return;
2219
2363
 
2364
+ const plans = await tx<{ plan: unknown }[]>`
2365
+ SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
2366
+ FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq <= ${checkpointSeq}
2367
+ AND ${visibleToTurn(input.turnId)}
2368
+ AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
2369
+ ORDER BY seq DESC, id DESC LIMIT 1
2370
+ `;
2371
+ const todoPlan = parseAiTodoPlan(plans[0]?.plan);
2220
2372
  const archived = await tx<{ count: number }[]>`
2221
2373
  WITH archived AS (
2222
2374
  UPDATE ai.messages
@@ -2224,6 +2376,7 @@ export const aiConversations: AiConversationService = {
2224
2376
  WHERE conversation_id = ${input.conversationId}
2225
2377
  AND compacted_at IS NULL
2226
2378
  AND seq <= ${checkpointSeq}
2379
+ AND ${visibleToTurn(input.turnId)}
2227
2380
  RETURNING id
2228
2381
  )
2229
2382
  SELECT COUNT(*)::int AS count FROM archived
@@ -2232,41 +2385,46 @@ export const aiConversations: AiConversationService = {
2232
2385
  await insertMessageLocked(
2233
2386
  {
2234
2387
  conversationId: input.conversationId,
2235
- message: input.summary,
2388
+ message:
2389
+ todoPlan && input.summary.role === "assistant"
2390
+ ? {
2391
+ ...input.summary,
2392
+ content: [
2393
+ ...input.summary.content,
2394
+ { type: "text", text: `Working plan at this checkpoint:\n${JSON.stringify(todoPlan)}` },
2395
+ ],
2396
+ }
2397
+ : input.summary,
2236
2398
  kind: "summary",
2237
2399
  seq: checkpointSeq,
2238
2400
  loopId: null,
2239
2401
  modelProfileId: input.modelProfileId ?? null,
2240
- meta: { compactedCount: archived[0]?.count ?? 0 },
2402
+ meta: { compactedCount: archived[0]?.count ?? 0, ...(todoPlan ? { todoPlan } : {}) },
2241
2403
  },
2242
2404
  tx,
2243
2405
  );
2244
2406
  });
2245
2407
  },
2246
2408
 
2247
- createCompactionTurn: async (input) => {
2248
- const rows = await withAiShortId(
2249
- "idx_ai_turns_conversation_short_id",
2250
- (shortId) => sql<TurnRow[]>`
2251
- INSERT INTO ai.turns (
2252
- short_id,
2253
- conversation_id,
2254
- model_profile_id,
2255
- status,
2256
- run_config
2257
- )
2258
- VALUES (
2259
- ${shortId},
2260
- ${input.conversationId},
2261
- ${input.modelProfileId},
2262
- 'queued',
2263
- (${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb
2264
- )
2409
+ createCompactionTurn: async (input) =>
2410
+ sql.begin(async (tx) => {
2411
+ const [conversation] = await tx<{ id: string }[]>`
2412
+ SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE
2413
+ `;
2414
+ if (!conversation) throw new Error("Conversation not found.");
2415
+ const rows = await withAiShortIdForDb(
2416
+ tx,
2417
+ "idx_ai_turns_conversation_short_id",
2418
+ (attempt, shortId) => attempt<TurnRow[]>`
2419
+ INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
2420
+ VALUES (${shortId}, ${input.conversationId}, ${input.modelProfileId}, 'queued',
2421
+ (${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb)
2265
2422
  RETURNING *
2266
2423
  `,
2267
- );
2268
- return rowToTurn(rows[0]!);
2269
- },
2424
+ );
2425
+ await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
2426
+ return rowToTurn(rows[0]!);
2427
+ }),
2270
2428
 
2271
2429
  submitChatTurn: async (input) => {
2272
2430
  return await sql.begin(async (tx) => {
@@ -2274,6 +2432,20 @@ export const aiConversations: AiConversationService = {
2274
2432
  SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE
2275
2433
  `;
2276
2434
  if (!conversation) throw new Error("Conversation not found.");
2435
+ if (input.queuedMessageId) {
2436
+ const [head] = await tx<
2437
+ { id: string; status: string }[]
2438
+ >`SELECT id, status FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') ORDER BY position LIMIT 1`;
2439
+ const [busy] =
2440
+ await tx`SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued','running','waiting_for_action') LIMIT 1`;
2441
+ if (!head || head.id !== input.queuedMessageId || head.status !== "pending" || busy)
2442
+ throw new Error("Queued message is no longer ready.");
2443
+ }
2444
+ if (!input.queuedMessageId) {
2445
+ const [queued] =
2446
+ await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
2447
+ if (queued) throw new Error("Queued messages must be sent first.");
2448
+ }
2277
2449
  if (input.expectedDraftRevision !== undefined && Number(conversation.draft_revision) !== input.expectedDraftRevision) {
2278
2450
  throw new Error("Conversation draft changed before the turn was submitted.");
2279
2451
  }
@@ -2309,13 +2481,22 @@ export const aiConversations: AiConversationService = {
2309
2481
  RETURNING *
2310
2482
  `,
2311
2483
  );
2484
+ // Queue acceptance already recorded the user's interaction time.
2485
+ if (!input.queuedMessageId)
2486
+ await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
2312
2487
  const turn = rowToTurn(turnRows[0]!);
2313
2488
  const attachedFiles = input.runConfig.files?.attached ?? [];
2314
2489
  for (const file of attachedFiles) {
2315
- const copied = input.retrySourceTurnId
2316
- ? await tx<{ path: string }[]>`
2317
- INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
2318
- SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
2490
+ const copied = input.queuedMessageId
2491
+ ? await tx<
2492
+ { path: string }[]
2493
+ >`INSERT INTO ai.turn_files(turn_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
2494
+ SELECT ${turn.id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.queued_message_files
2495
+ WHERE message_id = ${input.queuedMessageId}::uuid AND path = ${file.path} RETURNING path`
2496
+ : input.retrySourceTurnId
2497
+ ? await tx<{ path: string }[]>`
2498
+ INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
2499
+ SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
2319
2500
  FROM ai.turn_files
2320
2501
  WHERE turn_id = ${input.retrySourceTurnId}::uuid
2321
2502
  AND path = ${file.path}
@@ -2324,9 +2505,9 @@ export const aiConversations: AiConversationService = {
2324
2505
  AND version = ${file.version ?? -1}
2325
2506
  RETURNING path
2326
2507
  `
2327
- : await tx<{ path: string }[]>`
2328
- INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
2329
- SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
2508
+ : await tx<{ path: string }[]>`
2509
+ INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
2510
+ SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
2330
2511
  FROM ai.files
2331
2512
  WHERE conversation_id = ${input.conversationId}::uuid
2332
2513
  AND path = ${file.path}
@@ -2342,6 +2523,7 @@ export const aiConversations: AiConversationService = {
2342
2523
  conversationId: input.conversationId,
2343
2524
  message: input.userMessage,
2344
2525
  loopId: turn.id,
2526
+ meta: input.expectedDraftRevision === undefined ? undefined : { submittedDraftRevision: input.expectedDraftRevision },
2345
2527
  },
2346
2528
  tx,
2347
2529
  );
@@ -2375,6 +2557,10 @@ export const aiConversations: AiConversationService = {
2375
2557
  WHERE id = ${input.conversationId}::uuid
2376
2558
  `;
2377
2559
  }
2560
+ if (input.queuedMessageId) {
2561
+ await tx`UPDATE ai.queued_messages SET status = 'submitted', submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${input.queuedMessageId}::uuid`;
2562
+ await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${input.queuedMessageId}::uuid`;
2563
+ }
2378
2564
  return { turn, message: rowToMessage(messageRow) };
2379
2565
  });
2380
2566
  },
@@ -2390,6 +2576,14 @@ export const aiConversations: AiConversationService = {
2390
2576
  return rows[0]?.run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null;
2391
2577
  },
2392
2578
 
2579
+ getLatestTurn: async (input) => {
2580
+ const [row] = await sql<
2581
+ TurnRow[]
2582
+ >`SELECT * FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false)
2583
+ ORDER BY created_at DESC, id DESC LIMIT 1`;
2584
+ return row ? rowToTurn(row) : null;
2585
+ },
2586
+
2393
2587
  getTurn: async (input) => {
2394
2588
  const rows = await sql<TurnRow[]>`
2395
2589
  SELECT *
@@ -2417,6 +2611,7 @@ export const aiConversations: AiConversationService = {
2417
2611
  SELECT *
2418
2612
  FROM ai.turns
2419
2613
  WHERE conversation_id = ${input.conversationId}
2614
+ AND NOT COALESCE(run_config ? 'background', false)
2420
2615
  AND status IN ('queued', 'running', 'waiting_for_action')
2421
2616
  ORDER BY created_at DESC
2422
2617
  LIMIT 1
@@ -2431,7 +2626,7 @@ export const aiConversations: AiConversationService = {
2431
2626
 
2432
2627
  claimTurn: async (input) => {
2433
2628
  const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
2434
- const runBudgetMs = boundedMs(input.runBudgetMs, 10 * 60_000, 10_000, 60 * 60_000);
2629
+ const runBudgetMs = Number.isFinite(input.runBudgetMs) && input.runBudgetMs >= 0 ? input.runBudgetMs : 30 * 60_000;
2435
2630
  const maxAttempts = Math.max(1, Math.floor(input.maxAttempts));
2436
2631
  const rows = await sql<TurnRow[]>`
2437
2632
  UPDATE ai.turns
@@ -2440,9 +2635,11 @@ export const aiConversations: AiConversationService = {
2440
2635
  lease_owner = ${input.leaseOwner},
2441
2636
  lease_expires_at = now() + (${leaseMs} * interval '1 millisecond'),
2442
2637
  heartbeat_at = now(),
2638
+ run_budget_ms = COALESCE(run_budget_ms, ${runBudgetMs}),
2443
2639
  deadline = CASE
2444
- WHEN ${input.from} = 'waiting' THEN now() + (${runBudgetMs} * interval '1 millisecond')
2445
- ELSE COALESCE(deadline, now() + (${runBudgetMs} * interval '1 millisecond'))
2640
+ WHEN COALESCE(run_budget_ms, ${runBudgetMs}) = 0 THEN NULL
2641
+ WHEN ${input.from} = 'waiting' OR deadline IS NULL THEN now() + (COALESCE(run_budget_ms, ${runBudgetMs}) * interval '1 millisecond')
2642
+ ELSE deadline
2446
2643
  END
2447
2644
  WHERE id = ${input.turnId}
2448
2645
  AND conversation_id = ${input.conversationId}
@@ -2686,7 +2883,7 @@ export const aiConversations: AiConversationService = {
2686
2883
  UPDATE ai.turns
2687
2884
  SET status = 'failed',
2688
2885
  completed_at = now(),
2689
- error = 'AI turn exceeded its execution budget.',
2886
+ error = 'Run time limit reached. You can continue the task in a new message.',
2690
2887
  lease_owner = NULL,
2691
2888
  lease_expires_at = NULL,
2692
2889
  live_blocks = NULL
@@ -2853,14 +3050,14 @@ export const aiConversations: AiConversationService = {
2853
3050
  ${input.callId},
2854
3051
  ${input.kind},
2855
3052
  ${input.name},
2856
- ${JSON.stringify(input.args ?? null)}::jsonb,
3053
+ ${JSON.stringify(input.args ?? null)}::text::jsonb,
2857
3054
  ${input.message ?? null},
2858
- ${input.review ? JSON.stringify(input.review) : null}::jsonb,
3055
+ ${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
2859
3056
  ${input.approvalScope},
2860
3057
  ${input.allowAlways},
2861
3058
  ${input.frontendMode ?? null},
2862
3059
  ${input.resolvedEvent ? "resolved" : "pending"},
2863
- ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::jsonb,
3060
+ ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
2864
3061
  CASE WHEN ${Boolean(input.resolvedEvent)} THEN now() ELSE NULL END
2865
3062
  )
2866
3063
  ON CONFLICT (turn_id, call_id)
@@ -2928,7 +3125,7 @@ export const aiConversations: AiConversationService = {
2928
3125
  const rows = await sql<PendingActionRow[]>`
2929
3126
  UPDATE ai.pending_actions
2930
3127
  SET status = 'resolved',
2931
- resolved_event = ${JSON.stringify(input.event)}::jsonb,
3128
+ resolved_event = ${JSON.stringify(input.event)}::text::jsonb,
2932
3129
  resolved_at = now()
2933
3130
  WHERE conversation_id = ${input.conversationId}
2934
3131
  AND turn_id = ${input.turnId}
@@ -3054,7 +3251,23 @@ export const aiConversations: AiConversationService = {
3054
3251
  createSessionStore: (input): SessionStore => ({
3055
3252
  load: async (): Promise<StoreEntry[]> => {
3056
3253
  // The loop must only ever see the active model context, never archived history.
3057
- const rows = await aiConversations.listContextMessages({ conversationId: input.conversationId });
3254
+ if (input.background && input.turnId) {
3255
+ const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
3256
+ const context = input.background.context;
3257
+ return [...context, ...own.map((row) => ({ seq: context.length + row.seq, kind: row.kind, message: row.message }))];
3258
+ }
3259
+ // Results delivered after this turn started belong to the next turn's context.
3260
+ // Compare durable message sequences rather than wall-clock timestamps.
3261
+ const rows = input.turnId
3262
+ ? (
3263
+ await sql<MessageRow[]>`
3264
+ SELECT * FROM ai.messages
3265
+ WHERE conversation_id = ${input.conversationId}::uuid AND compacted_at IS NULL
3266
+ AND ${visibleToTurn(input.turnId)}
3267
+ ORDER BY seq ASC
3268
+ `
3269
+ ).map(rowToMessage)
3270
+ : await aiConversations.listContextMessages({ conversationId: input.conversationId });
3058
3271
  return rows.map((row) => ({
3059
3272
  seq: row.seq,
3060
3273
  kind: row.kind,
@@ -3077,19 +3290,21 @@ export const aiConversations: AiConversationService = {
3077
3290
 
3078
3291
  if (input.turnId && input.leaseOwner) {
3079
3292
  const appended = await appendTurnOwnedMessage({
3293
+ background: Boolean(input.background),
3080
3294
  conversationId: input.conversationId,
3081
3295
  turnId: input.turnId,
3082
3296
  leaseOwner: input.leaseOwner,
3083
3297
  message,
3084
3298
  kind: opts?.kind,
3085
3299
  seq: opts?.seq,
3086
- loopId: opts?.kind === "summary" ? null : input.turnId,
3300
+ loopId: opts?.kind === "summary" && !input.background ? null : input.turnId,
3087
3301
  modelProfileId: input.modelProfileId,
3088
3302
  meta,
3089
3303
  });
3090
3304
  if (!appended) {
3091
3305
  throw new Error("AI turn lost its lease while writing a message.");
3092
3306
  }
3307
+ await input.onMessage?.(appended);
3093
3308
  return;
3094
3309
  }
3095
3310