@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
@@ -1,8 +1,13 @@
1
+ import { aiConversations } from "./store";
1
2
  import type { Message } from "@k2b/nessi";
2
3
  import { type SQL, sql } from "bun";
3
4
  import { logger } from "../services/logging";
4
5
  import {
5
6
  createMandate,
7
+ CapabilityGrantsSchema,
8
+ type CapabilityGrant,
9
+ updateMandatePolicy,
10
+ validateMandateIssueAuthority,
6
11
  type Mandate,
7
12
  type MandatePolicyV1,
8
13
  MandatePolicyV1Schema,
@@ -10,6 +15,7 @@ import {
10
15
  resumeMandate,
11
16
  revokeMandate,
12
17
  } from "../services/mandates";
18
+ import { getCapability } from "../_internal/registry";
13
19
  import { parsePgJsonValue } from "../services/postgres";
14
20
  import { withAiShortIdForDb } from "./short-id";
15
21
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
@@ -25,6 +31,7 @@ export type AiChatTask = {
25
31
  sponsorUserId: string;
26
32
  mandateId: string | null;
27
33
  mandateRevision: number | null;
34
+ grants: CapabilityGrant[];
28
35
  prompt: string;
29
36
  schedule: AiChatTaskSchedule;
30
37
  timezone: string;
@@ -45,6 +52,7 @@ export type AiChatTaskOccurrence = {
45
52
  state: AiChatTaskOccurrenceState;
46
53
  turnId: string | null;
47
54
  error: string | null;
55
+ resultText: string | null;
48
56
  createdAt: string;
49
57
  startedAt: string | null;
50
58
  completedAt: string | null;
@@ -59,6 +67,7 @@ type TaskRow = {
59
67
  sponsor_user_id: string;
60
68
  mandate_id: string | null;
61
69
  mandate_revision: number | bigint | null;
70
+ mandate_policy?: unknown;
62
71
  prompt: string;
63
72
  schedule_kind: "once" | "cron";
64
73
  run_at: Date | string | null;
@@ -91,6 +100,8 @@ type OccurrenceRow = {
91
100
  state: AiChatTaskOccurrenceState;
92
101
  turn_id: string | null;
93
102
  error: string | null;
103
+ result_text?: string | null;
104
+ request_key?: string;
94
105
  created_at: Date | string;
95
106
  started_at: Date | string | null;
96
107
  completed_at: Date | string | null;
@@ -99,21 +110,42 @@ type OccurrenceRow = {
99
110
  const iso = (value: Date | string): string => new Date(value).toISOString();
100
111
  const nullableIso = (value: Date | string | null): string | null => (value === null ? null : iso(value));
101
112
  const taskIdempotencyKey = (key: string): string => `task.create:${key}`;
102
- const CHAT_TASK_MANDATE_POLICY = {
113
+ const chatTaskMandatePolicy = (grants: CapabilityGrant[]): MandatePolicyV1 => ({
103
114
  version: 1,
104
- apps: "*",
105
- operations: "*",
106
- actions: "require_approval",
107
- } as const satisfies MandatePolicyV1;
115
+ apps: [...new Set(grants.map((grant) => grant.appId))],
116
+ operations: [
117
+ ...new Set(
118
+ grants.flatMap((grant) =>
119
+ grant.kind === "query"
120
+ ? [`capability.query:${grant.capabilityId}`]
121
+ : [`capability.action.review:${grant.capabilityId}`, `capability.action.run:${grant.capabilityId}`],
122
+ ),
123
+ ),
124
+ ],
125
+ actions: grants.length ? "preapproved" : "deny",
126
+ grants,
127
+ });
128
+ const validateTaskGrants = async (input: unknown): Promise<CapabilityGrant[]> => {
129
+ const grants = CapabilityGrantsSchema.parse(input);
130
+ for (const grant of grants) {
131
+ const entry = await getCapability(grant.appId);
132
+ const operation = (grant.kind === "query" ? entry?.manifest.queries : entry?.manifest.actions)?.find(
133
+ (item) => item.localId === grant.capabilityId,
134
+ );
135
+ if (!operation) throw new Error(`Capability unavailable: ${grant.appId}.${grant.capabilityId}`);
136
+ if (grant.kind === "action" && (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable")))
137
+ throw new Error(`Capability always requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
138
+ const properties = operation.inputSchema.properties ?? {};
139
+ for (const key of Object.keys(grant.fixedInput))
140
+ if (!Object.hasOwn(properties, key)) throw new Error(`Unknown fixed input ${key} for ${grant.appId}.${grant.capabilityId}`);
141
+ }
142
+ return grants;
143
+ };
108
144
  const mandateAuthority = (userId: string) => ({ kind: "interactive" as const, userId });
109
145
  const mandateWorkloadAuthority = { kind: "workload" as const, ownerAppId: "core" };
110
146
  const mandateError = (operation: string, result: { ok: false; error: { message: string } }): Error =>
111
147
  new AiChatTaskAuthorityError(`Could not ${operation} scheduled task mandate: ${result.error.message}`);
112
- const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean =>
113
- policy.version === CHAT_TASK_MANDATE_POLICY.version &&
114
- policy.apps === CHAT_TASK_MANDATE_POLICY.apps &&
115
- policy.operations === CHAT_TASK_MANDATE_POLICY.operations &&
116
- policy.actions === CHAT_TASK_MANDATE_POLICY.actions;
148
+ const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean => policy.grants !== undefined;
117
149
  const mandateRevision = async (
118
150
  task: { mandate_id: string; mandate_revision: number | bigint },
119
151
  state: "active" | "paused",
@@ -187,6 +219,26 @@ const stopTaskAdmission = async (
187
219
  revision = revision + 1, updated_at = now()
188
220
  WHERE id = ${task.id}::uuid AND state = 'active'
189
221
  `;
222
+ if (state === "needs_attention" && reason) {
223
+ const occurrences = await db<{ id: string }[]>`
224
+ UPDATE ai.chat_task_occurrences SET state = 'failed', error = ${reason}, completed_at = now()
225
+ WHERE task_id = ${task.id}::uuid AND state = 'queued' RETURNING id
226
+ `;
227
+ if (!occurrences.length) {
228
+ // Reconciliation can detect revoked authority before the next occurrence exists.
229
+ const failed = await withAiShortIdForDb(
230
+ db,
231
+ "ai_chat_task_occurrences_short_id_unique",
232
+ (attempt, shortId) => attempt<{ id: string }[]>`
233
+ INSERT INTO ai.chat_task_occurrences(short_id, task_id, scheduled_for, trigger, request_key, task_revision, state, error, completed_at)
234
+ VALUES (${shortId}, ${task.id}::uuid, now(), 'scheduled', ${`authority:${task.id}:${task.revision}`}, ${task.revision}, 'failed', ${reason}, now())
235
+ ON CONFLICT DO NOTHING RETURNING id
236
+ `,
237
+ );
238
+ occurrences.push(...failed);
239
+ }
240
+ for (const occurrence of occurrences) await deliverOccurrenceResult(db, task, occurrence.id, reason);
241
+ }
190
242
  return null;
191
243
  };
192
244
  /** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
@@ -233,14 +285,30 @@ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number
233
285
  if (!paused.ok) throw mandateError("pause", paused);
234
286
  return paused.data.revision;
235
287
  };
236
- const taskFingerprint = (input: { chatId: string; prompt: string; schedule: AiChatTaskSchedule; timezone: string }): string =>
237
- new Bun.CryptoHasher("sha256").update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone])).digest("hex");
238
- const taskSelect = sql`
239
- SELECT task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
288
+ const taskFingerprint = (input: {
289
+ chatId: string;
290
+ prompt: string;
291
+ schedule: AiChatTaskSchedule;
292
+ timezone: string;
293
+ grants?: CapabilityGrant[];
294
+ }): string =>
295
+ new Bun.CryptoHasher("sha256")
296
+ .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone, input.grants ?? []]))
297
+ .digest("hex");
298
+ const taskSelect = () => sql`
299
+ SELECT task.*, (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
240
300
  FROM ai.chat_tasks task
241
301
  JOIN ai.conversations conversation ON conversation.id = task.conversation_id
242
302
  `;
243
303
 
304
+ const taskGrants = (row: TaskRow): CapabilityGrant[] => {
305
+ try {
306
+ const policy = MandatePolicyV1Schema.safeParse(parsePgJsonValue(row.mandate_policy));
307
+ return policy.success ? (policy.data.grants ?? []) : [];
308
+ } catch {
309
+ return [];
310
+ }
311
+ };
244
312
  const toTask = (row: TaskRow): AiChatTask => ({
245
313
  id: row.id,
246
314
  shortId: row.short_id,
@@ -250,6 +318,7 @@ const toTask = (row: TaskRow): AiChatTask => ({
250
318
  sponsorUserId: row.sponsor_user_id,
251
319
  mandateId: row.mandate_id,
252
320
  mandateRevision: row.mandate_revision === null ? null : Number(row.mandate_revision),
321
+ grants: taskGrants(row),
253
322
  prompt: row.prompt,
254
323
  schedule: row.schedule_kind === "once" ? { kind: "once", runAt: iso(row.run_at!) } : { kind: "cron", cron: row.cron! },
255
324
  timezone: row.timezone,
@@ -269,6 +338,7 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
269
338
  state: row.state,
270
339
  turnId: row.turn_id,
271
340
  error: row.error,
341
+ resultText: row.result_text ?? null,
272
342
  createdAt: iso(row.created_at),
273
343
  startedAt: nullableIso(row.started_at),
274
344
  completedAt: nullableIso(row.completed_at),
@@ -277,7 +347,7 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
277
347
  const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
278
348
  sql.begin(async (tx) => {
279
349
  const [task] = await tx<TaskRow[]>`
280
- ${taskSelect}
350
+ ${taskSelect()}
281
351
  WHERE task.id = ${taskId}::uuid
282
352
  FOR UPDATE OF task
283
353
  `;
@@ -286,7 +356,7 @@ const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
286
356
  });
287
357
  const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
288
358
  const [task] = await db<TaskRow[]>`
289
- ${taskSelect}
359
+ ${taskSelect()}
290
360
  WHERE EXISTS (
291
361
  SELECT 1 FROM ai.chat_task_occurrences occurrence
292
362
  WHERE occurrence.id = ${occurrenceId}::uuid AND occurrence.task_id = task.id
@@ -297,7 +367,7 @@ const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRo
297
367
  };
298
368
  const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> => {
299
369
  const [task] = await db<TaskRow[]>`
300
- ${taskSelect}
370
+ ${taskSelect()}
301
371
  WHERE EXISTS (
302
372
  SELECT 1 FROM ai.chat_task_occurrences occurrence
303
373
  WHERE occurrence.turn_id = ${turnId}::uuid AND occurrence.task_id = task.id
@@ -307,7 +377,110 @@ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> =>
307
377
  return task ?? null;
308
378
  };
309
379
 
380
+ /** Called in the occurrence terminal transition transaction, so delivery is exactly once. */
381
+ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: string, resultText: string): Promise<void> => {
382
+ const [occurrence] = await db<OccurrenceRow[]>`
383
+ UPDATE ai.chat_task_occurrences SET result_text = ${resultText}, delivered_at = now()
384
+ WHERE id = ${occurrenceId}::uuid AND delivered_at IS NULL RETURNING *
385
+ `;
386
+ if (!occurrence) return;
387
+ const [destination] = await db<{ id: string }[]>`
388
+ SELECT id FROM ai.conversations WHERE id = ${task.conversation_id}::uuid AND archived_at IS NULL FOR UPDATE
389
+ `;
390
+ if (!destination) return;
391
+ const message: Message = { role: "assistant", content: [{ type: "text", text: resultText }] };
392
+ const meta = {
393
+ scheduledTask: {
394
+ taskId: task.short_id,
395
+ occurrenceId: occurrence.short_id,
396
+ scheduledFor: iso(occurrence.scheduled_for),
397
+ trigger: occurrence.trigger,
398
+ },
399
+ };
400
+ await withAiShortIdForDb(
401
+ db,
402
+ "idx_ai_messages_conversation_short_id",
403
+ (attempt, shortId) => attempt`
404
+ INSERT INTO ai.messages(short_id, conversation_id, seq, kind, role, message, search_text, meta)
405
+ VALUES (${shortId}, ${task.conversation_id}::uuid,
406
+ (SELECT COALESCE(MAX(seq),0)+1 FROM ai.messages WHERE conversation_id = ${task.conversation_id}::uuid),
407
+ 'message', 'assistant', ${JSON.stringify(message)}::text::jsonb, ${resultText}, ${JSON.stringify(meta)}::text::jsonb)
408
+ `,
409
+ );
410
+ await db`UPDATE ai.conversations SET done = NULL, last_used_at = now(), updated_at = now(), background_received_at = now() WHERE id = ${task.conversation_id}::uuid`;
411
+ };
412
+
310
413
  export const aiChatTasks = {
414
+ listActivities: async (input: { userId: string; limit?: number; offset?: number }) => {
415
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
416
+ const rows = await sql<(OccurrenceRow & { unread: boolean })[]>`
417
+ SELECT occurrence.*, (occurrence.delivered_at > COALESCE(conversation.last_viewed_at, '-infinity')) AS unread
418
+ FROM ai.chat_task_occurrences occurrence
419
+ JOIN ai.chat_tasks task ON task.id = occurrence.task_id
420
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
421
+ WHERE task.sponsor_user_id = ${input.userId}::uuid AND conversation.created_by_user_id = ${input.userId}::uuid
422
+ AND conversation.archived_at IS NULL
423
+ ORDER BY occurrence.created_at DESC, occurrence.id DESC LIMIT ${limit + 1} OFFSET ${Math.max(input.offset ?? 0, 0)}
424
+ `;
425
+ const page = rows.slice(0, limit);
426
+ if (!page.length) return { items: [], hasMore: false };
427
+ const tasks = await sql<TaskRow[]>`${taskSelect()} WHERE task.id IN
428
+ (SELECT value::uuid FROM jsonb_array_elements_text(${JSON.stringify([...new Set(page.map((row) => row.task_id))])}::text::jsonb))`;
429
+ const byId = new Map(tasks.map((row) => [row.id, toTask(row)]));
430
+ return {
431
+ items: page.flatMap((row) => {
432
+ const task = byId.get(row.task_id);
433
+ return task
434
+ ? [
435
+ {
436
+ task,
437
+ occurrence: toOccurrence(row),
438
+ chatTitle: task.chatTitle,
439
+ unread: Boolean(row.unread),
440
+ },
441
+ ]
442
+ : [];
443
+ }),
444
+ hasMore: rows.length > limit,
445
+ };
446
+ },
447
+
448
+ getOccurrenceDetail: async (input: { userId: string; taskId: string; occurrenceId: string }) => {
449
+ const task = await aiChatTasks.get({ userId: input.userId, taskId: input.taskId });
450
+ if (!task) return null;
451
+ const [row] = await sql<
452
+ OccurrenceRow[]
453
+ >`SELECT * FROM ai.chat_task_occurrences WHERE task_id = ${task.id}::uuid AND short_id = ${input.occurrenceId}`;
454
+ if (!row) return null;
455
+ const messages = row.turn_id
456
+ ? await aiConversations.listTurnMessages({ conversationId: task.conversationId, loopId: row.turn_id, includeCompacted: true })
457
+ : [];
458
+ return { task, occurrence: toOccurrence(row), messages };
459
+ },
460
+
461
+ validateGrants: validateTaskGrants,
462
+ authorizeCapability: async (input: {
463
+ mandate: { id: string; revision: number };
464
+ appId: string;
465
+ capabilityId: string;
466
+ kind: "query" | "action";
467
+ input: unknown;
468
+ approval?: "none" | "rememberable" | "always";
469
+ }): Promise<void> => {
470
+ const result = await validateMandateIssueAuthority({
471
+ mandateId: input.mandate.id,
472
+ expectedRevision: input.mandate.revision,
473
+ ownerAppId: "core",
474
+ targetAppId: input.appId,
475
+ operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
476
+ input: input.input,
477
+ capabilityApproval: input.approval,
478
+ });
479
+ if (!result.ok)
480
+ throw new AiChatTaskAuthorityError(
481
+ `Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
482
+ );
483
+ },
311
484
  list: async (input: {
312
485
  userId: string;
313
486
  chatId?: string;
@@ -316,7 +489,7 @@ export const aiChatTasks = {
316
489
  offset?: number;
317
490
  }): Promise<AiChatTask[]> => {
318
491
  const rows = await sql<TaskRow[]>`
319
- ${taskSelect}
492
+ ${taskSelect()}
320
493
  WHERE task.sponsor_user_id = ${input.userId}::uuid
321
494
  AND (${input.chatId ?? null}::text IS NULL OR conversation.short_id = ${input.chatId ?? null})
322
495
  AND (${input.state ?? null}::text IS NULL OR task.state = ${input.state ?? null})
@@ -329,7 +502,7 @@ export const aiChatTasks = {
329
502
 
330
503
  get: async (input: { userId: string; taskId: string }): Promise<AiChatTask | null> => {
331
504
  const rows = await sql<TaskRow[]>`
332
- ${taskSelect}
505
+ ${taskSelect()}
333
506
  WHERE task.sponsor_user_id = ${input.userId}::uuid
334
507
  AND task.short_id = ${input.taskId}
335
508
  LIMIT 1
@@ -343,6 +516,7 @@ export const aiChatTasks = {
343
516
  prompt: string;
344
517
  schedule: AiChatTaskSchedule;
345
518
  timezone: string;
519
+ grants?: CapabilityGrant[];
346
520
  idempotencyKey?: string;
347
521
  idempotencyFingerprint?: string;
348
522
  }): Promise<AiChatTask | null> => {
@@ -350,7 +524,7 @@ export const aiChatTasks = {
350
524
  const fingerprint = scopedKey ? (input.idempotencyFingerprint ?? taskFingerprint(input)) : null;
351
525
  if (input.idempotencyKey) {
352
526
  const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
353
- ${taskSelect}
527
+ ${taskSelect()}
354
528
  WHERE task.idempotency_key = ${scopedKey}
355
529
  AND task.sponsor_user_id = ${input.userId}::uuid
356
530
  LIMIT 1
@@ -360,6 +534,7 @@ export const aiChatTasks = {
360
534
  return toTask(existing[0]);
361
535
  }
362
536
  }
537
+ const grants = await validateTaskGrants(input.grants ?? []);
363
538
  let rows: TaskRow[];
364
539
  try {
365
540
  rows = await sql.begin(async (tx) => {
@@ -380,7 +555,7 @@ export const aiChatTasks = {
380
555
  ownerAppId: "core",
381
556
  workloadType: "ai.chat-task",
382
557
  workloadId: taskId,
383
- policy: CHAT_TASK_MANDATE_POLICY,
558
+ policy: chatTaskMandatePolicy(grants),
384
559
  },
385
560
  { db: tx },
386
561
  );
@@ -405,6 +580,7 @@ export const aiChatTasks = {
405
580
  );
406
581
  const task = inserted[0];
407
582
  if (!task) throw new AiChatTaskCreateRace();
583
+ task.mandate_policy = mandate.data.policy;
408
584
  return inserted;
409
585
  });
410
586
  } catch (error) {
@@ -414,7 +590,7 @@ export const aiChatTasks = {
414
590
  if (rows[0]) return toTask(rows[0]);
415
591
  if (!input.idempotencyKey) return null;
416
592
  const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
417
- ${taskSelect}
593
+ ${taskSelect()}
418
594
  WHERE task.idempotency_key = ${scopedKey}
419
595
  AND task.sponsor_user_id = ${input.userId}::uuid
420
596
  LIMIT 1
@@ -430,7 +606,7 @@ export const aiChatTasks = {
430
606
  idempotencyFingerprint: string;
431
607
  }): Promise<AiChatTask | null> => {
432
608
  const rows = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
433
- ${taskSelect}
609
+ ${taskSelect()}
434
610
  WHERE task.idempotency_key = ${taskIdempotencyKey(input.idempotencyKey)}
435
611
  AND task.sponsor_user_id = ${input.userId}::uuid
436
612
  LIMIT 1
@@ -444,12 +620,13 @@ export const aiChatTasks = {
444
620
  userId: string;
445
621
  taskId: string;
446
622
  prompt?: string;
623
+ grants?: CapabilityGrant[];
447
624
  schedule?: AiChatTaskSchedule;
448
625
  timezone?: string;
449
626
  }): Promise<AiChatTask | null> =>
450
627
  sql.begin(async (tx) => {
451
628
  const rows = await tx<TaskRow[]>`
452
- ${taskSelect}
629
+ ${taskSelect()}
453
630
  WHERE task.sponsor_user_id = ${input.userId}::uuid
454
631
  AND task.short_id = ${input.taskId}
455
632
  FOR UPDATE OF task
@@ -460,7 +637,7 @@ export const aiChatTasks = {
460
637
  const wasActive = current.state === "active";
461
638
  const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
462
639
  if (authorityStopped) {
463
- [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
640
+ [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
464
641
  if (!current) return null;
465
642
  }
466
643
  const nextTimezone = input.timezone ?? current.timezone;
@@ -470,10 +647,27 @@ export const aiChatTasks = {
470
647
  nextTimezone !== current.timezone
471
648
  : false;
472
649
  const promptChanged = input.prompt !== undefined && input.prompt.trim() !== current.prompt;
473
- if (!promptChanged && !scheduleChanged) return toTask(current);
650
+ const grants = input.grants === undefined ? undefined : await validateTaskGrants(input.grants);
651
+ let updatedMandateRevision = Number(current.mandate_revision);
652
+ if (grants !== undefined) {
653
+ const mandate = await loadTaskMandate(current, tx);
654
+ if (!mandate || !taskMandateMatches(current, mandate)) throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
655
+ const updated = await updateMandatePolicy(
656
+ {
657
+ mandateId: mandate.id,
658
+ expectedRevision: Number(mandate.revision),
659
+ authority: mandateAuthority(input.userId),
660
+ policy: chatTaskMandatePolicy(grants),
661
+ },
662
+ { db: tx },
663
+ );
664
+ if (!updated.ok) throw mandateError("update", updated);
665
+ updatedMandateRevision = updated.data.revision;
666
+ }
667
+ if (!promptChanged && !scheduleChanged && grants === undefined) return toTask(current);
474
668
  await tx`
475
669
  UPDATE ai.chat_tasks task
476
- SET prompt = ${input.prompt?.trim() ?? current.prompt},
670
+ SET mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
477
671
  schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
478
672
  run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
479
673
  cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
@@ -499,7 +693,7 @@ export const aiChatTasks = {
499
693
  WHERE task_id = ${current.id}::uuid AND state = 'queued'
500
694
  `;
501
695
  }
502
- const updated = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
696
+ const updated = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
503
697
  const next = updated[0]!;
504
698
  if (next.state === "active" && !wasActive) {
505
699
  const revision = await mandateRevision(requireTaskMandate(next), "active", input.userId, tx);
@@ -514,7 +708,7 @@ export const aiChatTasks = {
514
708
  setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
515
709
  return sql.begin(async (tx) => {
516
710
  const rows = await tx<TaskRow[]>`
517
- ${taskSelect}
711
+ ${taskSelect()}
518
712
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
519
713
  FOR UPDATE OF task
520
714
  `;
@@ -555,7 +749,7 @@ export const aiChatTasks = {
555
749
  delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
556
750
  return sql.begin(async (tx) => {
557
751
  const rows = await tx<TaskRow[]>`
558
- ${taskSelect}
752
+ ${taskSelect()}
559
753
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
560
754
  FOR UPDATE OF task
561
755
  `;
@@ -599,7 +793,7 @@ export const aiChatTasks = {
599
793
 
600
794
  listActiveCron: async (): Promise<AiChatTask[]> => {
601
795
  const rows = await sql<TaskRow[]>`
602
- ${taskSelect}
796
+ ${taskSelect()}
603
797
  WHERE task.state = 'active' AND task.schedule_kind = 'cron'
604
798
  `;
605
799
  const tasks: AiChatTask[] = [];
@@ -624,8 +818,14 @@ export const aiChatTasks = {
624
818
  expectedRevision?: number;
625
819
  }): Promise<AiChatTaskOccurrence | null> => {
626
820
  return sql.begin(async (tx) => {
627
- const [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
821
+ const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
628
822
  if (!current || !(await prepareTaskForExecution(current, tx))) return null;
823
+ // One occurrence per schedule; timer ticks during a run do not accumulate.
824
+ const [inFlight] = await tx<
825
+ OccurrenceRow[]
826
+ >`SELECT * FROM ai.chat_task_occurrences WHERE task_id = ${input.taskId}::uuid AND state IN ('queued','running') LIMIT 1`;
827
+ if (inFlight) return inFlight.request_key === input.requestKey ? toOccurrence(inFlight) : null;
828
+
629
829
  const rows = await withAiShortIdForDb(
630
830
  tx,
631
831
  "ai_chat_task_occurrences_short_id_unique",
@@ -686,20 +886,20 @@ export const aiChatTasks = {
686
886
  occurrence.state, occurrence.turn_id, occurrence.error, occurrence.created_at, occurrence.started_at, occurrence.completed_at,
687
887
  task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
688
888
  conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
889
+ (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
689
890
  task.prompt, task.schedule_kind, task.run_at, task.cron,
690
891
  task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
691
892
  FROM (
692
893
  SELECT candidate.*,
693
- row_number() OVER (PARTITION BY task.conversation_id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
894
+ row_number() OVER (PARTITION BY task.id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
694
895
  FROM ai.chat_task_occurrences candidate
695
896
  JOIN ai.chat_tasks task ON task.id = candidate.task_id
696
897
  JOIN ai.conversations conversation ON conversation.id = task.conversation_id
697
898
  WHERE candidate.state = 'queued' AND task.state = 'active'
698
899
  AND task.mandate_id IS NOT NULL AND task.mandate_revision IS NOT NULL
699
900
  AND NOT EXISTS (
700
- SELECT 1 FROM ai.turns turn
701
- WHERE turn.conversation_id = task.conversation_id
702
- AND turn.status IN ('queued', 'running', 'waiting_for_action')
901
+ SELECT 1 FROM ai.chat_task_occurrences running
902
+ WHERE running.task_id = task.id AND running.state = 'running'
703
903
  )
704
904
  ) occurrence
705
905
  JOIN ai.chat_tasks task ON task.id = occurrence.task_id
@@ -728,7 +928,7 @@ export const aiChatTasks = {
728
928
  const occurrence = occurrences[0];
729
929
  if (!occurrence) return null;
730
930
  const tasks = await tx<TaskRow[]>`
731
- ${taskSelect}
931
+ ${taskSelect()}
732
932
  WHERE task.id = ${occurrence.task_id}::uuid AND task.state = 'active'
733
933
  LIMIT 1
734
934
  FOR UPDATE OF task
@@ -759,6 +959,7 @@ export const aiChatTasks = {
759
959
  RETURNING occurrence.task_id, occurrence.task_revision
760
960
  `;
761
961
  if (rows[0]) {
962
+ await deliverOccurrenceResult(tx, task, input.occurrenceId, input.error);
762
963
  const revision = await pauseTerminalTaskMandate(task, tx);
763
964
  await tx`
764
965
  UPDATE ai.chat_tasks
@@ -821,15 +1022,26 @@ export const aiChatTasks = {
821
1022
  if (occurrence.archived_at) return { delivered: false as const, reason: "not_found" as const };
822
1023
  if (Number(occurrence.task_revision) !== input.expectedRevision) return { delivered: false as const, reason: "stale" as const };
823
1024
  const active = await tx<{ id: string }[]>`
824
- SELECT id FROM ai.turns
825
- WHERE conversation_id = ${occurrence.conversation_id}::uuid
826
- AND status IN ('queued', 'running', 'waiting_for_action')
1025
+ SELECT id FROM ai.chat_task_occurrences
1026
+ WHERE task_id = ${task.id}::uuid AND state = 'running'
827
1027
  LIMIT 1
828
1028
  `;
829
1029
  if (active[0]) return { delivered: false as const, reason: "busy" as const };
830
1030
  const mandate = requireTaskMandate(task);
1031
+ const contextRows = await tx<{ seq: number; kind: "message" | "summary"; message: Message }[]>`
1032
+ SELECT message.seq, message.kind, message.message FROM ai.messages message
1033
+ WHERE message.conversation_id = ${occurrence.conversation_id}::uuid AND message.compacted_at IS NULL
1034
+ AND NOT EXISTS (SELECT 1 FROM ai.turns pending WHERE pending.id::text = message.loop_id
1035
+ AND pending.status IN ('queued','running','waiting_for_action'))
1036
+ ORDER BY message.seq
1037
+ `;
831
1038
  const runConfig: AiChatTurnRunConfig = {
832
1039
  ...input.runConfig,
1040
+ background: {
1041
+ taskId: task.short_id,
1042
+ occurrenceId: occurrence.short_id,
1043
+ context: contextRows.map((row, index) => ({ seq: index + 1, kind: row.kind, message: row.message })),
1044
+ },
833
1045
  mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
834
1046
  };
835
1047
  const turn = await withAiShortIdForDb(
@@ -854,16 +1066,16 @@ export const aiChatTasks = {
854
1066
  tx,
855
1067
  "idx_ai_messages_conversation_short_id",
856
1068
  (attempt, shortId) => attempt`
857
- INSERT INTO ai.messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
1069
+ INSERT INTO ai.task_messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
858
1070
  VALUES (
859
1071
  ${shortId}, ${occurrence.conversation_id}::uuid,
860
- (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${occurrence.conversation_id}::uuid AND seq > 0),
1072
+ 1,
861
1073
  'message', 'user', (${JSON.stringify(input.userMessage)}::text)::jsonb, ${input.runConfig.input}, ${turnId}::uuid,
862
1074
  (${JSON.stringify(messageMeta)}::text)::jsonb
863
1075
  )
864
1076
  `,
865
1077
  );
866
- await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${occurrence.conversation_id}::uuid`;
1078
+
867
1079
  await tx`
868
1080
  UPDATE ai.chat_task_occurrences
869
1081
  SET state = 'running', turn_id = ${turnId}::uuid, task_revision = ${occurrence.task_revision}::bigint, started_at = now()
@@ -908,6 +1120,27 @@ export const aiChatTasks = {
908
1120
  `;
909
1121
  const row = rows[0];
910
1122
  if (!row) return null;
1123
+ const responses = await tx<{ message: Message }[]>`
1124
+ SELECT message FROM ai.task_messages WHERE loop_id = ${input.turnId} AND role = 'assistant' ORDER BY seq DESC
1125
+ `;
1126
+ const resultText =
1127
+ responses
1128
+ .map((response) => {
1129
+ const message = response.message;
1130
+ if (message.role !== "assistant" || message.content.some((part) => part.type === "tool_call")) return "";
1131
+ return message.content
1132
+ .filter((part) => typeof part === "string" || part.type === "text")
1133
+ .map((part) => (typeof part === "string" ? part : part.type === "text" ? part.text : ""))
1134
+ .join("\n")
1135
+ .trim();
1136
+ })
1137
+ .find(Boolean) ||
1138
+ input.error ||
1139
+ row.turn_error ||
1140
+ (input.status === "completed"
1141
+ ? "Background task completed."
1142
+ : "Background task could not finish. Please review this run in the chat.");
1143
+ await deliverOccurrenceResult(tx, task, row.occurrence_id, resultText);
911
1144
  const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
912
1145
  const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
913
1146
  if (