@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
@@ -1,5 +1,6 @@
1
1
  import type { Message } from "@k2b/nessi";
2
2
  import { type SQL, sql } from "bun";
3
+ import { getCapability } from "../_internal/registry";
3
4
  import { logger } from "../services/logging";
4
5
  import {
5
6
  createMandate,
@@ -9,9 +10,14 @@ import {
9
10
  pauseMandate,
10
11
  resumeMandate,
11
12
  revokeMandate,
13
+ type TaskGrant,
14
+ TaskGrantsSchema,
15
+ updateMandatePolicy,
16
+ validateMandateIssueAuthority,
12
17
  } from "../services/mandates";
13
18
  import { parsePgJsonValue } from "../services/postgres";
14
19
  import { withAiShortIdForDb } from "./short-id";
20
+ import { aiConversations } from "./store";
15
21
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
16
22
 
17
23
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
@@ -25,6 +31,7 @@ export type AiChatTask = {
25
31
  sponsorUserId: string;
26
32
  mandateId: string | null;
27
33
  mandateRevision: number | null;
34
+ grants: TaskGrant[];
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,45 @@ 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: TaskGrant[]): 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) => ("appId" in grant ? grant.appId : "assistant")))],
116
+ operations: [
117
+ ...new Set(
118
+ grants.flatMap((grant) =>
119
+ !("appId" in grant)
120
+ ? [`runtime.${grant.kind}`]
121
+ : grant.kind === "query"
122
+ ? [`capability.query:${grant.capabilityId}`]
123
+ : [`capability.action.review:${grant.capabilityId}`, `capability.action.run:${grant.capabilityId}`],
124
+ ),
125
+ ),
126
+ ],
127
+ actions: grants.length ? "preapproved" : "deny",
128
+ grants,
129
+ });
130
+ const validateTaskGrants = async (input: unknown): Promise<TaskGrant[]> => {
131
+ const grants = TaskGrantsSchema.parse(input);
132
+ for (const grant of grants) {
133
+ if (!("appId" in grant)) continue;
134
+ const entry = await getCapability(grant.appId);
135
+ const operation = (grant.kind === "query" ? entry?.manifest.queries : entry?.manifest.actions)?.find(
136
+ (item) => item.localId === grant.capabilityId,
137
+ );
138
+ if (!operation) throw new Error(`Capability unavailable: ${grant.appId}.${grant.capabilityId}`);
139
+ if (grant.kind === "action" && (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable")))
140
+ throw new Error(`Capability always requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
141
+ const properties = operation.inputSchema.properties ?? {};
142
+ for (const key of Object.keys(grant.fixedInput))
143
+ if (!Object.hasOwn(properties, key)) throw new Error(`Unknown fixed input ${key} for ${grant.appId}.${grant.capabilityId}`);
144
+ }
145
+ return grants;
146
+ };
108
147
  const mandateAuthority = (userId: string) => ({ kind: "interactive" as const, userId });
109
148
  const mandateWorkloadAuthority = { kind: "workload" as const, ownerAppId: "core" };
110
149
  const mandateError = (operation: string, result: { ok: false; error: { message: string } }): Error =>
111
150
  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;
151
+ const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean => policy.grants !== undefined;
117
152
  const mandateRevision = async (
118
153
  task: { mandate_id: string; mandate_revision: number | bigint },
119
154
  state: "active" | "paused",
@@ -187,6 +222,26 @@ const stopTaskAdmission = async (
187
222
  revision = revision + 1, updated_at = now()
188
223
  WHERE id = ${task.id}::uuid AND state = 'active'
189
224
  `;
225
+ if (state === "needs_attention" && reason) {
226
+ const occurrences = await db<{ id: string }[]>`
227
+ UPDATE ai.chat_task_occurrences SET state = 'failed', error = ${reason}, completed_at = now()
228
+ WHERE task_id = ${task.id}::uuid AND state = 'queued' RETURNING id
229
+ `;
230
+ if (!occurrences.length) {
231
+ // Reconciliation can detect revoked authority before the next occurrence exists.
232
+ const failed = await withAiShortIdForDb(
233
+ db,
234
+ "ai_chat_task_occurrences_short_id_unique",
235
+ (attempt, shortId) => attempt<{ id: string }[]>`
236
+ INSERT INTO ai.chat_task_occurrences(short_id, task_id, scheduled_for, trigger, request_key, task_revision, state, error, completed_at)
237
+ VALUES (${shortId}, ${task.id}::uuid, now(), 'scheduled', ${`authority:${task.id}:${task.revision}`}, ${task.revision}, 'failed', ${reason}, now())
238
+ ON CONFLICT DO NOTHING RETURNING id
239
+ `,
240
+ );
241
+ occurrences.push(...failed);
242
+ }
243
+ for (const occurrence of occurrences) await deliverOccurrenceResult(db, task, occurrence.id, reason);
244
+ }
190
245
  return null;
191
246
  };
192
247
  /** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
@@ -233,14 +288,30 @@ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number
233
288
  if (!paused.ok) throw mandateError("pause", paused);
234
289
  return paused.data.revision;
235
290
  };
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
291
+ const taskFingerprint = (input: {
292
+ chatId: string;
293
+ prompt: string;
294
+ schedule: AiChatTaskSchedule;
295
+ timezone: string;
296
+ grants?: TaskGrant[];
297
+ }): string =>
298
+ new Bun.CryptoHasher("sha256")
299
+ .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone, input.grants ?? []]))
300
+ .digest("hex");
301
+ const taskSelect = () => sql`
302
+ 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
303
  FROM ai.chat_tasks task
241
304
  JOIN ai.conversations conversation ON conversation.id = task.conversation_id
242
305
  `;
243
306
 
307
+ const taskGrants = (row: TaskRow): TaskGrant[] => {
308
+ try {
309
+ const policy = MandatePolicyV1Schema.safeParse(parsePgJsonValue(row.mandate_policy));
310
+ return policy.success ? (policy.data.grants ?? []) : [];
311
+ } catch {
312
+ return [];
313
+ }
314
+ };
244
315
  const toTask = (row: TaskRow): AiChatTask => ({
245
316
  id: row.id,
246
317
  shortId: row.short_id,
@@ -250,6 +321,7 @@ const toTask = (row: TaskRow): AiChatTask => ({
250
321
  sponsorUserId: row.sponsor_user_id,
251
322
  mandateId: row.mandate_id,
252
323
  mandateRevision: row.mandate_revision === null ? null : Number(row.mandate_revision),
324
+ grants: taskGrants(row),
253
325
  prompt: row.prompt,
254
326
  schedule: row.schedule_kind === "once" ? { kind: "once", runAt: iso(row.run_at!) } : { kind: "cron", cron: row.cron! },
255
327
  timezone: row.timezone,
@@ -269,6 +341,7 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
269
341
  state: row.state,
270
342
  turnId: row.turn_id,
271
343
  error: row.error,
344
+ resultText: row.result_text ?? null,
272
345
  createdAt: iso(row.created_at),
273
346
  startedAt: nullableIso(row.started_at),
274
347
  completedAt: nullableIso(row.completed_at),
@@ -277,7 +350,7 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
277
350
  const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
278
351
  sql.begin(async (tx) => {
279
352
  const [task] = await tx<TaskRow[]>`
280
- ${taskSelect}
353
+ ${taskSelect()}
281
354
  WHERE task.id = ${taskId}::uuid
282
355
  FOR UPDATE OF task
283
356
  `;
@@ -286,7 +359,7 @@ const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
286
359
  });
287
360
  const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
288
361
  const [task] = await db<TaskRow[]>`
289
- ${taskSelect}
362
+ ${taskSelect()}
290
363
  WHERE EXISTS (
291
364
  SELECT 1 FROM ai.chat_task_occurrences occurrence
292
365
  WHERE occurrence.id = ${occurrenceId}::uuid AND occurrence.task_id = task.id
@@ -297,7 +370,7 @@ const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRo
297
370
  };
298
371
  const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> => {
299
372
  const [task] = await db<TaskRow[]>`
300
- ${taskSelect}
373
+ ${taskSelect()}
301
374
  WHERE EXISTS (
302
375
  SELECT 1 FROM ai.chat_task_occurrences occurrence
303
376
  WHERE occurrence.turn_id = ${turnId}::uuid AND occurrence.task_id = task.id
@@ -307,7 +380,128 @@ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> =>
307
380
  return task ?? null;
308
381
  };
309
382
 
383
+ /** Called in the occurrence terminal transition transaction, so delivery is exactly once. */
384
+ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: string, resultText: string): Promise<void> => {
385
+ const [occurrence] = await db<OccurrenceRow[]>`
386
+ UPDATE ai.chat_task_occurrences SET result_text = ${resultText}, delivered_at = now()
387
+ WHERE id = ${occurrenceId}::uuid AND delivered_at IS NULL RETURNING *
388
+ `;
389
+ if (!occurrence) return;
390
+ const [destination] = await db<{ id: string }[]>`
391
+ SELECT id FROM ai.conversations WHERE id = ${task.conversation_id}::uuid AND archived_at IS NULL FOR UPDATE
392
+ `;
393
+ if (!destination) return;
394
+ const message: Message = { role: "assistant", content: [{ type: "text", text: resultText }] };
395
+ const meta = {
396
+ scheduledTask: {
397
+ taskId: task.short_id,
398
+ occurrenceId: occurrence.short_id,
399
+ scheduledFor: iso(occurrence.scheduled_for),
400
+ trigger: occurrence.trigger,
401
+ },
402
+ };
403
+ await withAiShortIdForDb(
404
+ db,
405
+ "idx_ai_messages_conversation_short_id",
406
+ (attempt, shortId) => attempt`
407
+ INSERT INTO ai.messages(short_id, conversation_id, seq, kind, role, message, search_text, meta)
408
+ VALUES (${shortId}, ${task.conversation_id}::uuid,
409
+ (SELECT COALESCE(MAX(seq),0)+1 FROM ai.messages WHERE conversation_id = ${task.conversation_id}::uuid),
410
+ 'message', 'assistant', ${JSON.stringify(message)}::text::jsonb, ${resultText}, ${JSON.stringify(meta)}::text::jsonb)
411
+ `,
412
+ );
413
+ 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`;
414
+ };
415
+
310
416
  export const aiChatTasks = {
417
+ listActivities: async (input: { userId: string; limit?: number; offset?: number }) => {
418
+ const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
419
+ const rows = await sql<(OccurrenceRow & { unread: boolean })[]>`
420
+ SELECT occurrence.*, (occurrence.delivered_at > COALESCE(conversation.last_viewed_at, '-infinity')) AS unread
421
+ FROM ai.chat_task_occurrences occurrence
422
+ JOIN ai.chat_tasks task ON task.id = occurrence.task_id
423
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
424
+ WHERE task.sponsor_user_id = ${input.userId}::uuid AND conversation.created_by_user_id = ${input.userId}::uuid
425
+ AND conversation.archived_at IS NULL
426
+ ORDER BY occurrence.created_at DESC, occurrence.id DESC LIMIT ${limit + 1} OFFSET ${Math.max(input.offset ?? 0, 0)}
427
+ `;
428
+ const page = rows.slice(0, limit);
429
+ if (!page.length) return { items: [], hasMore: false };
430
+ const tasks = await sql<TaskRow[]>`${taskSelect()} WHERE task.id IN
431
+ (SELECT value::uuid FROM jsonb_array_elements_text(${JSON.stringify([...new Set(page.map((row) => row.task_id))])}::text::jsonb))`;
432
+ const byId = new Map(tasks.map((row) => [row.id, toTask(row)]));
433
+ return {
434
+ items: page.flatMap((row) => {
435
+ const task = byId.get(row.task_id);
436
+ return task
437
+ ? [
438
+ {
439
+ task,
440
+ occurrence: toOccurrence(row),
441
+ chatTitle: task.chatTitle,
442
+ unread: Boolean(row.unread),
443
+ },
444
+ ]
445
+ : [];
446
+ }),
447
+ hasMore: rows.length > limit,
448
+ };
449
+ },
450
+
451
+ getOccurrenceDetail: async (input: { userId: string; taskId: string; occurrenceId: string }) => {
452
+ const task = await aiChatTasks.get({ userId: input.userId, taskId: input.taskId });
453
+ if (!task) return null;
454
+ const [row] = await sql<
455
+ OccurrenceRow[]
456
+ >`SELECT * FROM ai.chat_task_occurrences WHERE task_id = ${task.id}::uuid AND short_id = ${input.occurrenceId}`;
457
+ if (!row) return null;
458
+ const messages = row.turn_id
459
+ ? await aiConversations.listTurnMessages({ conversationId: task.conversationId, loopId: row.turn_id, includeCompacted: true })
460
+ : [];
461
+ return { task, occurrence: toOccurrence(row), messages };
462
+ },
463
+
464
+ validateGrants: validateTaskGrants,
465
+ authorizeRuntime: async (input: {
466
+ mandate: { id: string; revision: number };
467
+ kind: "http" | "database";
468
+ input: unknown;
469
+ }): Promise<void> => {
470
+ const result = await validateMandateIssueAuthority({
471
+ mandateId: input.mandate.id,
472
+ expectedRevision: input.mandate.revision,
473
+ ownerAppId: "core",
474
+ targetAppId: "assistant",
475
+ operation: `runtime.${input.kind}`,
476
+ input: input.input,
477
+ });
478
+ if (!result.ok)
479
+ throw new AiChatTaskAuthorityError(
480
+ `Background ${input.kind} access denied: ${result.error.message}. Update this task's grants in the normal chat.`,
481
+ );
482
+ },
483
+ authorizeCapability: async (input: {
484
+ mandate: { id: string; revision: number };
485
+ appId: string;
486
+ capabilityId: string;
487
+ kind: "query" | "action";
488
+ input: unknown;
489
+ approval?: "none" | "rememberable" | "always";
490
+ }): Promise<void> => {
491
+ const result = await validateMandateIssueAuthority({
492
+ mandateId: input.mandate.id,
493
+ expectedRevision: input.mandate.revision,
494
+ ownerAppId: "core",
495
+ targetAppId: input.appId,
496
+ operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
497
+ input: input.input,
498
+ capabilityApproval: input.approval,
499
+ });
500
+ if (!result.ok)
501
+ throw new AiChatTaskAuthorityError(
502
+ `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.`,
503
+ );
504
+ },
311
505
  list: async (input: {
312
506
  userId: string;
313
507
  chatId?: string;
@@ -316,7 +510,7 @@ export const aiChatTasks = {
316
510
  offset?: number;
317
511
  }): Promise<AiChatTask[]> => {
318
512
  const rows = await sql<TaskRow[]>`
319
- ${taskSelect}
513
+ ${taskSelect()}
320
514
  WHERE task.sponsor_user_id = ${input.userId}::uuid
321
515
  AND (${input.chatId ?? null}::text IS NULL OR conversation.short_id = ${input.chatId ?? null})
322
516
  AND (${input.state ?? null}::text IS NULL OR task.state = ${input.state ?? null})
@@ -329,7 +523,7 @@ export const aiChatTasks = {
329
523
 
330
524
  get: async (input: { userId: string; taskId: string }): Promise<AiChatTask | null> => {
331
525
  const rows = await sql<TaskRow[]>`
332
- ${taskSelect}
526
+ ${taskSelect()}
333
527
  WHERE task.sponsor_user_id = ${input.userId}::uuid
334
528
  AND task.short_id = ${input.taskId}
335
529
  LIMIT 1
@@ -343,6 +537,7 @@ export const aiChatTasks = {
343
537
  prompt: string;
344
538
  schedule: AiChatTaskSchedule;
345
539
  timezone: string;
540
+ grants?: TaskGrant[];
346
541
  idempotencyKey?: string;
347
542
  idempotencyFingerprint?: string;
348
543
  }): Promise<AiChatTask | null> => {
@@ -350,7 +545,7 @@ export const aiChatTasks = {
350
545
  const fingerprint = scopedKey ? (input.idempotencyFingerprint ?? taskFingerprint(input)) : null;
351
546
  if (input.idempotencyKey) {
352
547
  const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
353
- ${taskSelect}
548
+ ${taskSelect()}
354
549
  WHERE task.idempotency_key = ${scopedKey}
355
550
  AND task.sponsor_user_id = ${input.userId}::uuid
356
551
  LIMIT 1
@@ -360,6 +555,7 @@ export const aiChatTasks = {
360
555
  return toTask(existing[0]);
361
556
  }
362
557
  }
558
+ const grants = await validateTaskGrants(input.grants ?? []);
363
559
  let rows: TaskRow[];
364
560
  try {
365
561
  rows = await sql.begin(async (tx) => {
@@ -380,7 +576,7 @@ export const aiChatTasks = {
380
576
  ownerAppId: "core",
381
577
  workloadType: "ai.chat-task",
382
578
  workloadId: taskId,
383
- policy: CHAT_TASK_MANDATE_POLICY,
579
+ policy: chatTaskMandatePolicy(grants),
384
580
  },
385
581
  { db: tx },
386
582
  );
@@ -405,6 +601,7 @@ export const aiChatTasks = {
405
601
  );
406
602
  const task = inserted[0];
407
603
  if (!task) throw new AiChatTaskCreateRace();
604
+ task.mandate_policy = mandate.data.policy;
408
605
  return inserted;
409
606
  });
410
607
  } catch (error) {
@@ -414,7 +611,7 @@ export const aiChatTasks = {
414
611
  if (rows[0]) return toTask(rows[0]);
415
612
  if (!input.idempotencyKey) return null;
416
613
  const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
417
- ${taskSelect}
614
+ ${taskSelect()}
418
615
  WHERE task.idempotency_key = ${scopedKey}
419
616
  AND task.sponsor_user_id = ${input.userId}::uuid
420
617
  LIMIT 1
@@ -430,7 +627,7 @@ export const aiChatTasks = {
430
627
  idempotencyFingerprint: string;
431
628
  }): Promise<AiChatTask | null> => {
432
629
  const rows = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
433
- ${taskSelect}
630
+ ${taskSelect()}
434
631
  WHERE task.idempotency_key = ${taskIdempotencyKey(input.idempotencyKey)}
435
632
  AND task.sponsor_user_id = ${input.userId}::uuid
436
633
  LIMIT 1
@@ -444,12 +641,13 @@ export const aiChatTasks = {
444
641
  userId: string;
445
642
  taskId: string;
446
643
  prompt?: string;
644
+ grants?: TaskGrant[];
447
645
  schedule?: AiChatTaskSchedule;
448
646
  timezone?: string;
449
647
  }): Promise<AiChatTask | null> =>
450
648
  sql.begin(async (tx) => {
451
649
  const rows = await tx<TaskRow[]>`
452
- ${taskSelect}
650
+ ${taskSelect()}
453
651
  WHERE task.sponsor_user_id = ${input.userId}::uuid
454
652
  AND task.short_id = ${input.taskId}
455
653
  FOR UPDATE OF task
@@ -460,7 +658,7 @@ export const aiChatTasks = {
460
658
  const wasActive = current.state === "active";
461
659
  const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
462
660
  if (authorityStopped) {
463
- [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
661
+ [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
464
662
  if (!current) return null;
465
663
  }
466
664
  const nextTimezone = input.timezone ?? current.timezone;
@@ -470,10 +668,27 @@ export const aiChatTasks = {
470
668
  nextTimezone !== current.timezone
471
669
  : false;
472
670
  const promptChanged = input.prompt !== undefined && input.prompt.trim() !== current.prompt;
473
- if (!promptChanged && !scheduleChanged) return toTask(current);
671
+ const grants = input.grants === undefined ? undefined : await validateTaskGrants(input.grants);
672
+ let updatedMandateRevision = Number(current.mandate_revision);
673
+ if (grants !== undefined) {
674
+ const mandate = await loadTaskMandate(current, tx);
675
+ if (!mandate || !taskMandateMatches(current, mandate)) throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
676
+ const updated = await updateMandatePolicy(
677
+ {
678
+ mandateId: mandate.id,
679
+ expectedRevision: Number(mandate.revision),
680
+ authority: mandateAuthority(input.userId),
681
+ policy: chatTaskMandatePolicy(grants),
682
+ },
683
+ { db: tx },
684
+ );
685
+ if (!updated.ok) throw mandateError("update", updated);
686
+ updatedMandateRevision = updated.data.revision;
687
+ }
688
+ if (!promptChanged && !scheduleChanged && grants === undefined) return toTask(current);
474
689
  await tx`
475
690
  UPDATE ai.chat_tasks task
476
- SET prompt = ${input.prompt?.trim() ?? current.prompt},
691
+ SET mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
477
692
  schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
478
693
  run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
479
694
  cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
@@ -499,7 +714,7 @@ export const aiChatTasks = {
499
714
  WHERE task_id = ${current.id}::uuid AND state = 'queued'
500
715
  `;
501
716
  }
502
- const updated = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
717
+ const updated = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
503
718
  const next = updated[0]!;
504
719
  if (next.state === "active" && !wasActive) {
505
720
  const revision = await mandateRevision(requireTaskMandate(next), "active", input.userId, tx);
@@ -514,7 +729,7 @@ export const aiChatTasks = {
514
729
  setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
515
730
  return sql.begin(async (tx) => {
516
731
  const rows = await tx<TaskRow[]>`
517
- ${taskSelect}
732
+ ${taskSelect()}
518
733
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
519
734
  FOR UPDATE OF task
520
735
  `;
@@ -555,7 +770,7 @@ export const aiChatTasks = {
555
770
  delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
556
771
  return sql.begin(async (tx) => {
557
772
  const rows = await tx<TaskRow[]>`
558
- ${taskSelect}
773
+ ${taskSelect()}
559
774
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
560
775
  FOR UPDATE OF task
561
776
  `;
@@ -599,7 +814,7 @@ export const aiChatTasks = {
599
814
 
600
815
  listActiveCron: async (): Promise<AiChatTask[]> => {
601
816
  const rows = await sql<TaskRow[]>`
602
- ${taskSelect}
817
+ ${taskSelect()}
603
818
  WHERE task.state = 'active' AND task.schedule_kind = 'cron'
604
819
  `;
605
820
  const tasks: AiChatTask[] = [];
@@ -624,8 +839,14 @@ export const aiChatTasks = {
624
839
  expectedRevision?: number;
625
840
  }): Promise<AiChatTaskOccurrence | null> => {
626
841
  return sql.begin(async (tx) => {
627
- const [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
842
+ const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
628
843
  if (!current || !(await prepareTaskForExecution(current, tx))) return null;
844
+ // One occurrence per schedule; timer ticks during a run do not accumulate.
845
+ const [inFlight] = await tx<
846
+ OccurrenceRow[]
847
+ >`SELECT * FROM ai.chat_task_occurrences WHERE task_id = ${input.taskId}::uuid AND state IN ('queued','running') LIMIT 1`;
848
+ if (inFlight) return inFlight.request_key === input.requestKey ? toOccurrence(inFlight) : null;
849
+
629
850
  const rows = await withAiShortIdForDb(
630
851
  tx,
631
852
  "ai_chat_task_occurrences_short_id_unique",
@@ -686,20 +907,20 @@ export const aiChatTasks = {
686
907
  occurrence.state, occurrence.turn_id, occurrence.error, occurrence.created_at, occurrence.started_at, occurrence.completed_at,
687
908
  task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
688
909
  conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
910
+ (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
689
911
  task.prompt, task.schedule_kind, task.run_at, task.cron,
690
912
  task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
691
913
  FROM (
692
914
  SELECT candidate.*,
693
- row_number() OVER (PARTITION BY task.conversation_id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
915
+ row_number() OVER (PARTITION BY task.id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
694
916
  FROM ai.chat_task_occurrences candidate
695
917
  JOIN ai.chat_tasks task ON task.id = candidate.task_id
696
918
  JOIN ai.conversations conversation ON conversation.id = task.conversation_id
697
919
  WHERE candidate.state = 'queued' AND task.state = 'active'
698
920
  AND task.mandate_id IS NOT NULL AND task.mandate_revision IS NOT NULL
699
921
  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')
922
+ SELECT 1 FROM ai.chat_task_occurrences running
923
+ WHERE running.task_id = task.id AND running.state = 'running'
703
924
  )
704
925
  ) occurrence
705
926
  JOIN ai.chat_tasks task ON task.id = occurrence.task_id
@@ -728,7 +949,7 @@ export const aiChatTasks = {
728
949
  const occurrence = occurrences[0];
729
950
  if (!occurrence) return null;
730
951
  const tasks = await tx<TaskRow[]>`
731
- ${taskSelect}
952
+ ${taskSelect()}
732
953
  WHERE task.id = ${occurrence.task_id}::uuid AND task.state = 'active'
733
954
  LIMIT 1
734
955
  FOR UPDATE OF task
@@ -759,6 +980,7 @@ export const aiChatTasks = {
759
980
  RETURNING occurrence.task_id, occurrence.task_revision
760
981
  `;
761
982
  if (rows[0]) {
983
+ await deliverOccurrenceResult(tx, task, input.occurrenceId, input.error);
762
984
  const revision = await pauseTerminalTaskMandate(task, tx);
763
985
  await tx`
764
986
  UPDATE ai.chat_tasks
@@ -821,15 +1043,26 @@ export const aiChatTasks = {
821
1043
  if (occurrence.archived_at) return { delivered: false as const, reason: "not_found" as const };
822
1044
  if (Number(occurrence.task_revision) !== input.expectedRevision) return { delivered: false as const, reason: "stale" as const };
823
1045
  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')
1046
+ SELECT id FROM ai.chat_task_occurrences
1047
+ WHERE task_id = ${task.id}::uuid AND state = 'running'
827
1048
  LIMIT 1
828
1049
  `;
829
1050
  if (active[0]) return { delivered: false as const, reason: "busy" as const };
830
1051
  const mandate = requireTaskMandate(task);
1052
+ const contextRows = await tx<{ seq: number; kind: "message" | "summary"; message: Message }[]>`
1053
+ SELECT message.seq, message.kind, message.message FROM ai.messages message
1054
+ WHERE message.conversation_id = ${occurrence.conversation_id}::uuid AND message.compacted_at IS NULL
1055
+ AND NOT EXISTS (SELECT 1 FROM ai.turns pending WHERE pending.id::text = message.loop_id
1056
+ AND pending.status IN ('queued','running','waiting_for_action'))
1057
+ ORDER BY message.seq
1058
+ `;
831
1059
  const runConfig: AiChatTurnRunConfig = {
832
1060
  ...input.runConfig,
1061
+ background: {
1062
+ taskId: task.short_id,
1063
+ occurrenceId: occurrence.short_id,
1064
+ context: contextRows.map((row, index) => ({ seq: index + 1, kind: row.kind, message: row.message })),
1065
+ },
833
1066
  mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
834
1067
  };
835
1068
  const turn = await withAiShortIdForDb(
@@ -854,16 +1087,16 @@ export const aiChatTasks = {
854
1087
  tx,
855
1088
  "idx_ai_messages_conversation_short_id",
856
1089
  (attempt, shortId) => attempt`
857
- INSERT INTO ai.messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
1090
+ INSERT INTO ai.task_messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
858
1091
  VALUES (
859
1092
  ${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),
1093
+ 1,
861
1094
  'message', 'user', (${JSON.stringify(input.userMessage)}::text)::jsonb, ${input.runConfig.input}, ${turnId}::uuid,
862
1095
  (${JSON.stringify(messageMeta)}::text)::jsonb
863
1096
  )
864
1097
  `,
865
1098
  );
866
- await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${occurrence.conversation_id}::uuid`;
1099
+
867
1100
  await tx`
868
1101
  UPDATE ai.chat_task_occurrences
869
1102
  SET state = 'running', turn_id = ${turnId}::uuid, task_revision = ${occurrence.task_revision}::bigint, started_at = now()
@@ -908,6 +1141,27 @@ export const aiChatTasks = {
908
1141
  `;
909
1142
  const row = rows[0];
910
1143
  if (!row) return null;
1144
+ const responses = await tx<{ message: Message }[]>`
1145
+ SELECT message FROM ai.task_messages WHERE loop_id = ${input.turnId} AND role = 'assistant' ORDER BY seq DESC
1146
+ `;
1147
+ const resultText =
1148
+ responses
1149
+ .map((response) => {
1150
+ const message = response.message;
1151
+ if (message.role !== "assistant" || message.content.some((part) => part.type === "tool_call")) return "";
1152
+ return message.content
1153
+ .filter((part) => typeof part === "string" || part.type === "text")
1154
+ .map((part) => (typeof part === "string" ? part : part.type === "text" ? part.text : ""))
1155
+ .join("\n")
1156
+ .trim();
1157
+ })
1158
+ .find(Boolean) ||
1159
+ input.error ||
1160
+ row.turn_error ||
1161
+ (input.status === "completed"
1162
+ ? "Background task completed."
1163
+ : "Background task could not finish. Please review this run in the chat.");
1164
+ await deliverOccurrenceResult(tx, task, row.occurrence_id, resultText);
911
1165
  const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
912
1166
  const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
913
1167
  if (