@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,6 +1,7 @@
1
1
  import type { Message } from "@k2b/nessi";
2
2
  import { type SQL, sql } from "bun";
3
3
  import { getCapability } from "../_internal/registry";
4
+ import { type CapabilityActionPolicy, capabilityActionPolicy } from "../contracts/capabilities";
4
5
  import { logger } from "../services/logging";
5
6
  import {
6
7
  createMandate,
@@ -17,8 +18,23 @@ import {
17
18
  validateMandateIssueAuthority,
18
19
  } from "../services/mandates";
19
20
  import { parsePgJsonValue, toPgUuidArray } from "../services/postgres";
21
+ import { coreSettings } from "../services/settings/api";
22
+ import { canonicalLocale } from "../shared/locale";
23
+ import { chatTaskName } from "./chat-task-contracts";
24
+ import {
25
+ type ChatTaskReason,
26
+ type ChatTaskReasonCode,
27
+ chatTaskLocale,
28
+ chatTaskNeedsNewTime,
29
+ chatTaskReasonSentence,
30
+ chatTaskReasonState,
31
+ chatTaskStopSentence,
32
+ } from "./chat-task-reasons";
33
+ import { buildBlocksFromMessages } from "./protocol";
20
34
  import { withAiShortIdForDb } from "./short-id";
21
35
  import { aiConversations } from "./store";
36
+ import { toolOutcome, toolWording } from "./tool-wording";
37
+ import { finishAiTurnAbort } from "./turn-abort";
22
38
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
23
39
 
24
40
  /** Run history and transcripts are operational evidence; 90 days matches capability execution history.
@@ -28,6 +44,12 @@ export const AI_CHAT_TASK_RUN_RETENTION_DAYS = 90;
28
44
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
29
45
  export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
30
46
  export type AiChatTask = {
47
+ title: string | null;
48
+ name: string;
49
+ locale: string | null;
50
+ reasonCode: ChatTaskReasonCode | null;
51
+ reasonAt: string | null;
52
+ reasonAction: string | null;
31
53
  id: string;
32
54
  shortId: string;
33
55
  chatId: string;
@@ -47,7 +69,7 @@ export type AiChatTask = {
47
69
  updatedAt: string;
48
70
  };
49
71
 
50
- export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed";
72
+ export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed" | "stopped";
51
73
  export type AiChatTaskOccurrence = {
52
74
  id: string;
53
75
  shortId: string;
@@ -64,6 +86,11 @@ export type AiChatTaskOccurrence = {
64
86
  };
65
87
 
66
88
  type TaskRow = {
89
+ title: string | null;
90
+ locale: string | null;
91
+ reason_code: ChatTaskReasonCode | null;
92
+ reason_at: Date | string | null;
93
+ reason_action: string | null;
67
94
  id: string;
68
95
  short_id: string;
69
96
  conversation_id: string;
@@ -148,7 +175,14 @@ const validateTaskGrants = async (input: unknown): Promise<TaskGrant[]> => {
148
175
  (item) => item.localId === grant.capabilityId,
149
176
  );
150
177
  if (!operation) throw new Error(`Capability unavailable: ${grant.appId}.${grant.capabilityId}`);
151
- if (grant.kind === "action" && (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable")))
178
+ const policy = entry ? capabilityActionPolicy(entry.manifest, grant.capabilityId) : undefined;
179
+ if (grant.kind === "action" && policy && (!("silent" in policy) || policy.routines === false))
180
+ throw new Error(`Capability requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
181
+ if (
182
+ grant.kind === "action" &&
183
+ !policy &&
184
+ (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable"))
185
+ )
152
186
  throw new Error(`Capability always requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
153
187
  const properties = operation.inputSchema.properties ?? {};
154
188
  for (const key of Object.keys(grant.fixedInput))
@@ -222,17 +256,19 @@ const taskMandatePolicy = (mandate: TaskMandateRow): MandatePolicyV1 | null => {
222
256
  };
223
257
  const stopTaskAdmission = async (
224
258
  task: TaskRow,
225
- state: "paused" | "needs_attention",
259
+ code: ChatTaskReasonCode,
226
260
  reason: string | null,
227
261
  db: SQL,
228
- pausedMandateRevision?: number,
262
+ appLocale: string | undefined,
263
+ pausedMandateRevision?: number | null,
229
264
  ): Promise<null> => {
230
- await db`
265
+ const state = chatTaskReasonState(code);
266
+ const [stopped] = await db<TaskRow[]>`
231
267
  UPDATE ai.chat_tasks
232
- SET state = ${state}, last_error = ${reason},
268
+ SET state = ${state}, last_error = ${reason}, reason_code = ${code}, reason_action = NULL, reason_at = now(),
233
269
  mandate_revision = COALESCE(${pausedMandateRevision ?? null}::bigint, mandate_revision),
234
270
  revision = revision + 1, updated_at = now()
235
- WHERE id = ${task.id}::uuid AND state = 'active'
271
+ WHERE id = ${task.id}::uuid AND state = ${task.state} RETURNING *
236
272
  `;
237
273
  if (state === "needs_attention" && reason) {
238
274
  const occurrences = await db<{ id: string }[]>`
@@ -252,26 +288,45 @@ const stopTaskAdmission = async (
252
288
  );
253
289
  occurrences.push(...failed);
254
290
  }
255
- for (const occurrence of occurrences) await deliverOccurrenceResult(db, task, occurrence.id, reason);
291
+ for (const occurrence of occurrences) {
292
+ const taskReason = { code, at: iso(stopped!.reason_at!) };
293
+ await deliverOccurrenceResult(db, task, occurrence.id, reasonSentence(task, taskReason, appLocale), {
294
+ reason: taskReason,
295
+ reasonOnly: true,
296
+ });
297
+ }
256
298
  }
257
299
  return null;
258
300
  };
259
301
  /** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
260
- const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow | null> => {
302
+ const prepareTaskForExecution = async (input: TaskRow, db: SQL, appLocale: string | undefined): Promise<TaskRow | null> => {
261
303
  if (input.state !== "active") return null;
262
304
  const task = input;
263
305
  const mandate = await loadTaskMandate(task, db);
264
306
  const policy = mandate ? taskMandatePolicy(mandate) : null;
265
307
  const [sponsor] = await db<{ id: string }[]>`
266
- SELECT sponsor.id FROM auth.users sponsor
267
- JOIN ai.conversations conversation ON conversation.created_by_user_id = sponsor.id
268
- WHERE sponsor.id = ${task.sponsor_user_id}::uuid AND conversation.id = ${task.conversation_id}::uuid
269
- AND (sponsor.account_expires IS NULL OR sponsor.account_expires > now()) AND conversation.archived_at IS NULL
270
- FOR SHARE OF sponsor
271
- FOR UPDATE OF conversation
272
- `;
308
+ SELECT id FROM auth.users WHERE id = ${task.sponsor_user_id}::uuid
309
+ AND (account_expires IS NULL OR account_expires > now()) FOR SHARE`;
310
+ const [conversation] = await db<{ archived_at: Date | null; created_by_user_id: string }[]>`
311
+ SELECT archived_at, created_by_user_id FROM ai.conversations WHERE id = ${task.conversation_id}::uuid FOR UPDATE`;
312
+ if (!sponsor)
313
+ return stopTaskAdmission(
314
+ task,
315
+ "owner_inactive",
316
+ "Scheduled task sponsor is unavailable",
317
+ db,
318
+ appLocale,
319
+ await pauseTerminalTaskMandate(task, db),
320
+ );
321
+ const archived = () =>
322
+ pauseTerminalTaskMandate(task, db).then((revision) =>
323
+ stopTaskAdmission(task, "chat_archived", "Scheduled task chat is archived or unavailable", db, appLocale, revision),
324
+ );
325
+ if (!conversation) return archived();
326
+ // Authority comes before the archive: a stop or change of what the routine may do must not turn into an archive
327
+ // pause that restoring the chat would quietly undo.
273
328
  if (
274
- !sponsor ||
329
+ conversation.created_by_user_id !== task.sponsor_user_id ||
275
330
  !mandate ||
276
331
  !taskMandateMatches(task, mandate) ||
277
332
  !mandate.confirmed_at ||
@@ -280,12 +335,13 @@ const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow
280
335
  !isChatTaskMandatePolicy(policy) ||
281
336
  mandate.state === "revoked"
282
337
  ) {
283
- return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate or sponsor is unavailable", db);
338
+ return stopTaskAdmission(task, "rights_changed", "Scheduled task mandate or sponsor is unavailable", db, appLocale);
284
339
  }
285
- if (mandate.state === "paused") return stopTaskAdmission(task, "paused", null, db, Number(mandate.revision));
340
+ if (mandate.state === "paused") return stopTaskAdmission(task, "paused", null, db, appLocale, Number(mandate.revision));
286
341
  if (task.mandate_revision === null || Number(task.mandate_revision) !== Number(mandate.revision)) {
287
- return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate revision changed", db);
342
+ return stopTaskAdmission(task, "rights_changed", "Scheduled task mandate revision changed", db, appLocale);
288
343
  }
344
+ if (conversation.archived_at) return archived();
289
345
  return task;
290
346
  };
291
347
  /** Terminal accounting may narrow existing authority, but must never create or resume it. */
@@ -303,12 +359,23 @@ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number
303
359
  const taskFingerprint = (input: {
304
360
  chatId: string;
305
361
  prompt: string;
362
+ title?: string;
306
363
  schedule: AiChatTaskSchedule;
307
364
  timezone: string;
308
365
  grants?: TaskGrant[];
309
366
  }): string =>
310
367
  new Bun.CryptoHasher("sha256")
311
- .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone, input.grants ?? []]))
368
+ .update(
369
+ // Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
370
+ JSON.stringify([
371
+ input.chatId,
372
+ input.prompt.trim(),
373
+ input.schedule,
374
+ input.timezone,
375
+ input.grants ?? [],
376
+ ...(input.title?.trim() ? [input.title.trim()] : []),
377
+ ]),
378
+ )
312
379
  .digest("hex");
313
380
  const taskSelect = () => sql`
314
381
  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
@@ -325,6 +392,13 @@ const taskGrants = (row: TaskRow): TaskGrant[] => {
325
392
  }
326
393
  };
327
394
  const toTask = (row: TaskRow): AiChatTask => ({
395
+ title: row.title,
396
+ name: chatTaskName({ title: row.title, chatTitle: row.conversation_title, prompt: row.prompt }),
397
+ locale: row.locale,
398
+ reasonCode: row.reason_code,
399
+ reasonAt: nullableIso(row.reason_at),
400
+ // An older Core may leave a stale action beside another code; the action belongs only to needs_approval.
401
+ reasonAction: row.reason_code === "needs_approval" ? row.reason_action : null,
328
402
  id: row.id,
329
403
  shortId: row.short_id,
330
404
  chatId: row.conversation_short_id,
@@ -360,13 +434,13 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
360
434
  });
361
435
 
362
436
  const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
363
- sql.begin(async (tx) => {
437
+ beginWithAppLocale(async (tx, appLocale) => {
364
438
  const [task] = await tx<TaskRow[]>`
365
439
  ${taskSelect()}
366
440
  WHERE task.id = ${taskId}::uuid
367
441
  FOR UPDATE OF task
368
442
  `;
369
- const prepared = task ? await prepareTaskForExecution(task, tx) : null;
443
+ const prepared = task ? await prepareTaskForExecution(task, tx, appLocale) : null;
370
444
  return prepared ? toTask(prepared) : null;
371
445
  });
372
446
  const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
@@ -392,8 +466,26 @@ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> =>
392
466
  return task ?? null;
393
467
  };
394
468
 
469
+ /**
470
+ * A transaction that may word a routine's reason or stop line. The app locale is read first: on a cache miss the
471
+ * settings read takes another pooled connection, which must never wait inside a transaction holding task locks.
472
+ */
473
+ const beginWithAppLocale = async <T>(run: (tx: SQL, appLocale: string | undefined) => Promise<T>): Promise<T> => {
474
+ const appLocale = await coreSettings.get<string>("app.locale");
475
+ return sql.begin((tx) => run(tx, appLocale));
476
+ };
477
+
395
478
  /** Called in the occurrence terminal transition transaction, so delivery is exactly once. */
396
- const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: string, resultText: string): Promise<void> => {
479
+ const reasonSentence = (task: TaskRow, reason: ChatTaskReason, appLocale: string | undefined): string =>
480
+ chatTaskReasonSentence(reason, { locale: chatTaskLocale(task.locale, appLocale), timeZone: task.timezone });
481
+
482
+ const deliverOccurrenceResult = async (
483
+ db: SQL,
484
+ task: TaskRow,
485
+ occurrenceId: string,
486
+ resultText: string,
487
+ extra: Pick<NonNullable<NonNullable<AiStoredMessage["meta"]>["scheduledTask"]>, "reason" | "reasonOnly" | "stop"> = {},
488
+ ): Promise<void> => {
397
489
  const [occurrence] = await db<OccurrenceRow[]>`
398
490
  UPDATE ai.chat_task_occurrences SET result_text = ${resultText}, delivered_at = now()
399
491
  WHERE id = ${occurrenceId}::uuid AND delivered_at IS NULL RETURNING *
@@ -410,6 +502,7 @@ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: str
410
502
  occurrenceId: occurrence.short_id,
411
503
  scheduledFor: iso(occurrence.scheduled_for),
412
504
  trigger: occurrence.trigger,
505
+ ...extra,
413
506
  },
414
507
  };
415
508
  await withAiShortIdForDb(
@@ -425,6 +518,98 @@ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: str
425
518
  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`;
426
519
  };
427
520
 
521
+ type StopCause = "paused" | "deleted";
522
+ const withTaskAbortTransaction = async <T>(
523
+ run: (tx: SQL, stop: (task: TaskRow, cause: StopCause) => Promise<void>) => Promise<T>,
524
+ ): Promise<T> => {
525
+ const aborts: Array<{
526
+ input: { conversationId: string; turnId: string };
527
+ request: Awaited<ReturnType<typeof aiConversations.requestTurnAbort>>;
528
+ }> = [];
529
+ const result = await beginWithAppLocale(async (tx, appLocale) =>
530
+ run(tx, async (task, cause) => {
531
+ const turns = await tx<{ turn_id: string; scheduled_for: Date | string; trigger: "scheduled" | "manual" }[]>`
532
+ SELECT turn_id, scheduled_for, trigger FROM ai.chat_task_occurrences
533
+ WHERE task_id = ${task.id}::uuid AND state = 'running' AND turn_id IS NOT NULL`;
534
+ for (const turn of turns) {
535
+ const input = { conversationId: task.conversation_id, turnId: turn.turn_id };
536
+ // Keep the run's delivery coordinates even when its task/occurrence is about to cascade away.
537
+ const locale = chatTaskLocale(task.locale, appLocale);
538
+ await tx`UPDATE ai.turns SET run_config = jsonb_set(
539
+ jsonb_set(run_config, '{locale}', to_jsonb(COALESCE(run_config->>'locale', ${locale}))),
540
+ '{background}', jsonb_build_object('scheduledFor', ${iso(turn.scheduled_for)}::text,
541
+ 'trigger', ${turn.trigger}::text, 'timezone', ${task.timezone}::text) || (run_config->'background'))
542
+ WHERE id = ${turn.turn_id}::uuid AND run_config ? 'background'`;
543
+ const request = await aiConversations.requestTurnAbort(
544
+ { ...input, reason: cause === "paused" ? "routine_paused" : "routine_deleted" },
545
+ { db: tx },
546
+ );
547
+ aborts.push({ input, request });
548
+ }
549
+ }),
550
+ );
551
+ for (const { input, request } of aborts) {
552
+ await finishAiTurnAbort(input, request);
553
+ if (request.found && request.ownerless) await aiChatTasks.finalizeTurn({ turnId: input.turnId, status: "aborted" });
554
+ }
555
+ return result;
556
+ };
557
+
558
+ type StoppedTurn = { id: string; conversation_id: string; cancellation_reason: string | null; run_config: AiChatTurnRunConfig };
559
+ /** The turn lock and persisted receipt survive deletion of the task and its occurrences. */
560
+ /** Returns the stop line once; `null` when another finalization already claimed it. */
561
+ const deliverStoppedTurn = async (
562
+ db: SQL,
563
+ turn: StoppedTurn,
564
+ cause: StopCause,
565
+ task: TaskRow | null,
566
+ appLocale: string | undefined,
567
+ ): Promise<string | null> => {
568
+ const config = turn.run_config;
569
+ if (!config.background) return null;
570
+ // Chat before turn, the order archiving locks them in, so the two never wait on each other.
571
+ const [conversation] = await db<{ archived_at: Date | null }[]>`
572
+ SELECT archived_at FROM ai.conversations WHERE id = ${turn.conversation_id}::uuid FOR UPDATE`;
573
+ const [claimed] = await db`UPDATE ai.turns SET run_config = jsonb_set(run_config, '{background,stopDeliveredAt}', to_jsonb(now()::text))
574
+ WHERE id = ${turn.id}::uuid AND run_config #>> '{background,stopDeliveredAt}' IS NULL RETURNING id`;
575
+ if (!claimed) return null;
576
+ const locale = chatTaskLocale(config.locale ?? task?.locale, appLocale);
577
+ const messages = await db<{ seq: number; message: Message; meta: AiStoredMessage["meta"] }[]>`
578
+ SELECT seq, message, meta FROM ai.task_messages WHERE loop_id = ${turn.id} ORDER BY seq`;
579
+ const done = buildBlocksFromMessages(messages).flatMap((block) => {
580
+ if (block.kind !== "tool" || block.status !== "completed" || block.isError) return [];
581
+ // Web reads have chat wording too, but do not change anything for a stop receipt.
582
+ if (block.name === "web_extract") return [];
583
+ if (block.result && typeof block.result === "object" && "ok" in block.result && block.result.ok === false) return [];
584
+ const wording = toolWording(block, locale);
585
+ if (!wording) return [];
586
+ // An Action without a done sentence still changed something; its title names it instead of dropping it.
587
+ return [toolOutcome(block, "done", locale, config.background?.timezone ?? task?.timezone ?? "UTC") ?? wording.title];
588
+ });
589
+ const text = chatTaskStopSentence(done, locale);
590
+ if (!conversation || conversation.archived_at) return text;
591
+ const scheduledTask = {
592
+ taskId: config.background.taskId,
593
+ occurrenceId: config.background.occurrenceId,
594
+ scheduledFor: config.background.scheduledFor ?? "",
595
+ trigger: config.background.trigger ?? "scheduled",
596
+ stop: { cause, done },
597
+ };
598
+ const message: Message = { role: "assistant", content: [{ type: "text", text }] };
599
+ await withAiShortIdForDb(
600
+ db,
601
+ "idx_ai_messages_conversation_short_id",
602
+ (attempt, shortId) => attempt`
603
+ INSERT INTO ai.messages(short_id, conversation_id, seq, kind, role, message, search_text, meta)
604
+ VALUES (${shortId}, ${turn.conversation_id}::uuid,
605
+ (SELECT COALESCE(MAX(seq),0)+1 FROM ai.messages WHERE conversation_id = ${turn.conversation_id}::uuid),
606
+ 'message', 'assistant', ${JSON.stringify(message)}::text::jsonb, ${text}, ${JSON.stringify({ scheduledTask })}::text::jsonb)`,
607
+ );
608
+ await db`UPDATE ai.conversations SET done = NULL, last_used_at = now(), updated_at = now(), background_received_at = now()
609
+ WHERE id = ${turn.conversation_id}::uuid`;
610
+ return text;
611
+ };
612
+
428
613
  export const aiChatTasks = {
429
614
  listActivities: async (input: { userId: string; limit?: number; offset?: number }) => {
430
615
  const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
@@ -499,6 +684,7 @@ export const aiChatTasks = {
499
684
  kind: "query" | "action";
500
685
  input: unknown;
501
686
  approval?: "none" | "rememberable" | "always";
687
+ policy?: CapabilityActionPolicy;
502
688
  }): Promise<void> => {
503
689
  const result = await validateMandateIssueAuthority({
504
690
  mandateId: input.mandate.id,
@@ -508,6 +694,7 @@ export const aiChatTasks = {
508
694
  operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
509
695
  input: input.input,
510
696
  capabilityApproval: input.approval,
697
+ capabilityPolicy: input.policy,
511
698
  });
512
699
  if (!result.ok) {
513
700
  const policyDenied = result.error.code === "FORBIDDEN" && result.error.message === MANDATE_POLICY_DENIED_MESSAGE;
@@ -552,6 +739,8 @@ export const aiChatTasks = {
552
739
  userId: string;
553
740
  chatId: string;
554
741
  prompt: string;
742
+ title?: string;
743
+ locale?: string;
555
744
  schedule: AiChatTaskSchedule;
556
745
  timezone: string;
557
746
  grants?: TaskGrant[];
@@ -603,11 +792,11 @@ export const aiChatTasks = {
603
792
  "ai_chat_tasks_short_id_unique",
604
793
  (attempt, shortId) => attempt<TaskRow[]>`
605
794
  INSERT INTO ai.chat_tasks (
606
- id, short_id, conversation_id, sponsor_user_id, prompt, schedule_kind, run_at, cron, timezone,
795
+ id, short_id, conversation_id, sponsor_user_id, prompt, title, locale, schedule_kind, run_at, cron, timezone,
607
796
  mandate_id, mandate_revision, idempotency_key, idempotency_fingerprint
608
797
  )
609
798
  VALUES (
610
- ${taskId}::uuid, ${shortId}, ${conversation.id}::uuid, ${input.userId}::uuid, ${input.prompt.trim()}, ${input.schedule.kind},
799
+ ${taskId}::uuid, ${shortId}, ${conversation.id}::uuid, ${input.userId}::uuid, ${input.prompt.trim()}, ${input.title?.trim() ?? null}, ${canonicalLocale(input.locale) ?? null}, ${input.schedule.kind},
611
800
  ${input.schedule.kind === "once" ? input.schedule.runAt : null}::timestamptz,
612
801
  ${input.schedule.kind === "cron" ? input.schedule.cron : null}, ${input.timezone},
613
802
  ${mandate.data.id}::uuid, ${mandate.data.revision}, ${scopedKey}, ${fingerprint}
@@ -658,11 +847,12 @@ export const aiChatTasks = {
658
847
  userId: string;
659
848
  taskId: string;
660
849
  prompt?: string;
850
+ title?: string;
661
851
  grants?: TaskGrant[];
662
852
  schedule?: AiChatTaskSchedule;
663
853
  timezone?: string;
664
854
  }): Promise<AiChatTask | null> =>
665
- sql.begin(async (tx) => {
855
+ beginWithAppLocale(async (tx, appLocale) => {
666
856
  const rows = await tx<TaskRow[]>`
667
857
  ${taskSelect()}
668
858
  WHERE task.sponsor_user_id = ${input.userId}::uuid
@@ -673,7 +863,7 @@ export const aiChatTasks = {
673
863
  if (!current) return null;
674
864
  requireTaskMandate(current);
675
865
  const wasActive = current.state === "active";
676
- const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
866
+ const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx, appLocale));
677
867
  if (authorityStopped) {
678
868
  [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
679
869
  if (!current) return null;
@@ -702,17 +892,27 @@ export const aiChatTasks = {
702
892
  if (!updated.ok) throw mandateError("update", updated);
703
893
  updatedMandateRevision = updated.data.revision;
704
894
  }
705
- if (!promptChanged && !scheduleChanged && grants === undefined) return toTask(current);
895
+ const titleChanged = input.title !== undefined && input.title.trim() !== current.title;
896
+ if (!promptChanged && !scheduleChanged && grants === undefined) {
897
+ if (!titleChanged) return toTask(current);
898
+ // A new name changes nothing about when or how the routine runs: state, reason and revision stay.
899
+ const [renamed] = await tx<TaskRow[]>`
900
+ UPDATE ai.chat_tasks task SET title = ${input.title!.trim()}, updated_at = now()
901
+ FROM ai.conversations conversation
902
+ WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
903
+ RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title`;
904
+ return renamed ? toTask({ ...renamed, mandate_policy: current.mandate_policy }) : null;
905
+ }
706
906
  await tx`
707
907
  UPDATE ai.chat_tasks task
708
- SET mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
908
+ SET title = ${input.title?.trim() ?? current.title}, mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
709
909
  schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
710
910
  run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
711
911
  cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
712
912
  timezone = ${input.timezone ?? current.timezone},
713
913
  state = CASE
714
914
  WHEN ${authorityStopped} THEN task.state
715
- WHEN task.state = 'paused' THEN 'paused'
915
+ WHEN task.state = 'paused' THEN ${chatTaskReasonState("paused")}
716
916
  WHEN task.state = 'completed' AND ${scheduleChanged} = false THEN 'completed'
717
917
  ELSE 'active'
718
918
  END,
@@ -721,6 +921,9 @@ export const aiChatTasks = {
721
921
  THEN task.last_error
722
922
  ELSE NULL
723
923
  END,
924
+ reason_code = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_code ELSE NULL END,
925
+ reason_action = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_action ELSE NULL END,
926
+ reason_at = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_at ELSE NULL END,
724
927
  revision = task.revision + 1, updated_at = now()
725
928
  WHERE id = ${current.id}::uuid
726
929
  `;
@@ -743,8 +946,96 @@ export const aiChatTasks = {
743
946
  return toTask(next);
744
947
  }),
745
948
 
746
- setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
949
+ pauseForArchivedChat: async (input: { conversationId: string; userId?: string }): Promise<void> => {
950
+ await beginWithAppLocale(async (tx, appLocale) => {
951
+ const tasks = await tx<TaskRow[]>`${taskSelect()}
952
+ WHERE task.conversation_id = ${input.conversationId}::uuid AND task.state = 'active'
953
+ AND (${input.userId ?? null}::uuid IS NULL OR task.sponsor_user_id = ${input.userId ?? null}::uuid)
954
+ ORDER BY task.id FOR UPDATE OF task`;
955
+ if (!tasks.length) return;
956
+ // Locks in admission order (tasks, then chat). A restore that committed first leaves its routines running;
957
+ // one that comes later waits for this pause and then resumes them.
958
+ const [archived] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
959
+ AND archived_at IS NOT NULL FOR UPDATE`;
960
+ if (!archived) return;
961
+ // Admission decides the reason: an archive pause only for a routine whose authority is still intact.
962
+ for (const task of tasks) await prepareTaskForExecution(task, tx, appLocale);
963
+ });
964
+ },
965
+
966
+ resumeForRestoredChat: async (input: { conversationId: string; userId?: string; resume: boolean }): Promise<void> => {
967
+ const tasks = await sql<TaskRow[]>`${taskSelect()}
968
+ WHERE task.conversation_id = ${input.conversationId}::uuid AND task.reason_code = 'chat_archived'
969
+ AND (${input.userId ?? null}::uuid IS NULL OR task.sponsor_user_id = ${input.userId ?? null}::uuid)`;
970
+ for (const task of tasks) {
971
+ try {
972
+ if (
973
+ input.resume &&
974
+ (await aiChatTasks.setState({
975
+ userId: task.sponsor_user_id,
976
+ taskId: task.short_id,
977
+ state: "active",
978
+ expectedReason: "chat_archived",
979
+ }))
980
+ )
981
+ continue;
982
+ await sql.begin(async (tx) => {
983
+ const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${task.id}::uuid FOR UPDATE OF task`;
984
+ // Archived again meanwhile: the routine stays paused for that reason.
985
+ const [restored] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
986
+ AND archived_at IS NULL FOR SHARE`;
987
+ if (current?.reason_code === "chat_archived" && restored)
988
+ await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', reason_action = NULL, updated_at = now() WHERE id = ${current.id}::uuid`;
989
+ });
990
+ } catch (error) {
991
+ if (!(error instanceof AiChatTaskAuthorityError)) throw error;
992
+ await beginWithAppLocale(async (tx, appLocale) => {
993
+ const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${task.id}::uuid FOR UPDATE OF task`;
994
+ // Archived again meanwhile: the routine stays paused for that reason.
995
+ const [restored] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
996
+ AND archived_at IS NULL FOR SHARE`;
997
+ if (current?.reason_code === "chat_archived" && restored)
998
+ await stopTaskAdmission(current, "rights_changed", error.message, tx, appLocale);
999
+ });
1000
+ }
1001
+ }
1002
+ },
1003
+
1004
+ /** Run before a restore: the fresh timestamp keeps the recovery backstop away from routines this restore handles. */
1005
+ markRestoreStarted: async (input: { conversationId: string; userId?: string }): Promise<void> => {
1006
+ await sql`UPDATE ai.chat_tasks SET updated_at = now()
1007
+ WHERE conversation_id = ${input.conversationId}::uuid AND reason_code = 'chat_archived'
1008
+ AND (${input.userId ?? null}::uuid IS NULL OR sponsor_user_id = ${input.userId ?? null}::uuid)`;
1009
+ },
1010
+
1011
+ /**
1012
+ * Recovery backstop for a restore whose routine step failed: archive pauses of restored chats become plain pauses,
1013
+ * so they never name an archive that has ended. It never resumes; the grace leaves a restore in progress alone.
1014
+ */
1015
+ relabelRestoredArchivePauses: async (input: { olderThanMs?: number; limit?: number } = {}): Promise<number> => {
1016
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
1017
+ const graceSeconds = Math.max(input.olderThanMs ?? 5 * 60_000, 0) / 1000;
747
1018
  return sql.begin(async (tx) => {
1019
+ const rows = await tx<{ id: string }[]>`
1020
+ SELECT task.id FROM ai.chat_tasks task
1021
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
1022
+ WHERE task.reason_code = 'chat_archived' AND conversation.archived_at IS NULL
1023
+ AND task.updated_at < now() - make_interval(secs => ${graceSeconds}::double precision)
1024
+ ORDER BY task.id LIMIT ${limit}
1025
+ FOR UPDATE OF task SKIP LOCKED FOR SHARE OF conversation SKIP LOCKED`;
1026
+ for (const row of rows)
1027
+ await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', reason_action = NULL, updated_at = now() WHERE id = ${row.id}::uuid`;
1028
+ return rows.length;
1029
+ });
1030
+ },
1031
+
1032
+ setState: async (input: {
1033
+ userId: string;
1034
+ taskId: string;
1035
+ state: "active" | "paused";
1036
+ expectedReason?: ChatTaskReasonCode;
1037
+ }): Promise<AiChatTask | null> => {
1038
+ return withTaskAbortTransaction(async (tx, stop) => {
748
1039
  const rows = await tx<TaskRow[]>`
749
1040
  ${taskSelect()}
750
1041
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
@@ -752,11 +1043,18 @@ export const aiChatTasks = {
752
1043
  `;
753
1044
  let current = rows[0];
754
1045
  if (!current) return null;
1046
+ if (input.expectedReason && current.reason_code !== input.expectedReason) return null;
1047
+ // An automatic resume continues only the authority it paused; any change since then needs a confirmation.
1048
+ if (input.expectedReason) {
1049
+ const paused = await loadTaskMandate(current, tx);
1050
+ if (!paused || current.mandate_revision === null || Number(paused.revision) !== Number(current.mandate_revision))
1051
+ throw new AiChatTaskAuthorityError("Scheduled task mandate changed while the routine was paused");
1052
+ }
755
1053
  requireTaskMandate(current);
756
1054
  const allowed =
757
1055
  current.state === input.state ||
758
1056
  (current.state === "active" && input.state === "paused") ||
759
- (current.state === "paused" && input.state === "active") ||
1057
+ (current.state === "paused" && input.state === "active" && !chatTaskNeedsNewTime(toTask(current))) ||
760
1058
  (current.state === "needs_attention" && current.schedule_kind === "cron" && input.state === "active");
761
1059
  if (!allowed) return null;
762
1060
  const mandate = await loadTaskMandate(current, tx);
@@ -764,6 +1062,13 @@ export const aiChatTasks = {
764
1062
  throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
765
1063
  }
766
1064
  if (input.state === "active") {
1065
+ const [available] = await tx`SELECT sponsor.id FROM auth.users sponsor JOIN ai.conversations conversation
1066
+ ON conversation.created_by_user_id = sponsor.id WHERE sponsor.id = ${current.sponsor_user_id}::uuid
1067
+ AND conversation.id = ${current.conversation_id}::uuid AND conversation.archived_at IS NULL
1068
+ AND (sponsor.account_expires IS NULL OR sponsor.account_expires > now())
1069
+ FOR SHARE OF conversation`;
1070
+ // Held until this resume commits, so an archive waits for it and its follow-up then pauses the routine again.
1071
+ if (!available) throw new AiChatTaskAuthorityError("Scheduled task sponsor or chat is unavailable");
767
1072
  const policy = taskMandatePolicy(mandate);
768
1073
  if (!mandate.confirmed_at || !mandate.unexpired || !policy || !isChatTaskMandatePolicy(policy)) {
769
1074
  throw new AiChatTaskAuthorityError("Scheduled task mandate cannot be resumed");
@@ -772,7 +1077,10 @@ export const aiChatTasks = {
772
1077
  const revision = await mandateRevision({ mandate_id: mandate.id, mandate_revision: mandate.revision }, input.state, input.userId, tx);
773
1078
  const updated = await tx<TaskRow[]>`
774
1079
  UPDATE ai.chat_tasks task
775
- SET state = ${input.state}, mandate_revision = ${revision},
1080
+ SET state = ${input.state === "paused" ? chatTaskReasonState("paused") : "active"}, mandate_revision = ${revision},
1081
+ reason_code = ${input.state === "paused" ? "paused" : null},
1082
+ reason_action = NULL,
1083
+ reason_at = CASE WHEN ${input.state} = 'active' THEN NULL WHEN task.reason_code = 'paused' THEN task.reason_at ELSE now() END,
776
1084
  last_error = CASE WHEN task.state = ${input.state} THEN task.last_error ELSE NULL END,
777
1085
  revision = CASE WHEN task.state = ${input.state} THEN task.revision ELSE task.revision + 1 END,
778
1086
  updated_at = CASE WHEN task.state = ${input.state} THEN task.updated_at ELSE now() END
@@ -780,12 +1088,13 @@ export const aiChatTasks = {
780
1088
  WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
781
1089
  RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
782
1090
  `;
1091
+ if (input.state === "paused") await stop(current, "paused");
783
1092
  return updated[0] ? toTask(updated[0]) : null;
784
1093
  });
785
1094
  },
786
1095
 
787
1096
  delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
788
- return sql.begin(async (tx) => {
1097
+ return withTaskAbortTransaction(async (tx, stop) => {
789
1098
  const rows = await tx<TaskRow[]>`
790
1099
  ${taskSelect()}
791
1100
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
@@ -793,6 +1102,7 @@ export const aiChatTasks = {
793
1102
  `;
794
1103
  let current = rows[0];
795
1104
  if (!current) return false;
1105
+ await stop(current, "deleted");
796
1106
  if (!current.mandate_id && current.mandate_revision === null) {
797
1107
  await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
798
1108
  return true;
@@ -855,9 +1165,9 @@ export const aiChatTasks = {
855
1165
  requestKey: string;
856
1166
  expectedRevision?: number;
857
1167
  }): Promise<AiChatTaskOccurrence | null> => {
858
- return sql.begin(async (tx) => {
1168
+ return beginWithAppLocale(async (tx, appLocale) => {
859
1169
  const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
860
- if (!current || !(await prepareTaskForExecution(current, tx))) return null;
1170
+ if (!current || !(await prepareTaskForExecution(current, tx, appLocale))) return null;
861
1171
  // One occurrence per schedule; timer ticks during a run do not accumulate.
862
1172
  const [inFlight] = await tx<
863
1173
  OccurrenceRow[]
@@ -930,7 +1240,7 @@ export const aiChatTasks = {
930
1240
  AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
931
1241
  ) AS keep
932
1242
  FROM ai.chat_task_occurrences occurrence
933
- WHERE occurrence.state IN ('completed', 'failed') AND occurrence.completed_at < ${cutoff}
1243
+ WHERE occurrence.state IN ('completed', 'failed', 'stopped') AND occurrence.completed_at < ${cutoff}
934
1244
  AND (occurrence.turn_id IS NOT NULL OR NOT EXISTS (
935
1245
  SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
936
1246
  AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
@@ -985,7 +1295,7 @@ export const aiChatTasks = {
985
1295
  task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
986
1296
  conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
987
1297
  (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
988
- task.prompt, task.schedule_kind, task.run_at, task.cron,
1298
+ task.title, task.locale, task.reason_code, task.reason_at, task.reason_action, task.prompt, task.schedule_kind, task.run_at, task.cron,
989
1299
  task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
990
1300
  FROM (
991
1301
  SELECT candidate.*,
@@ -1019,7 +1329,7 @@ export const aiChatTasks = {
1019
1329
  },
1020
1330
 
1021
1331
  getQueuedOccurrence: async (occurrenceId: string): Promise<{ occurrence: AiChatTaskOccurrence; task: AiChatTask } | null> => {
1022
- return sql.begin(async (tx) => {
1332
+ return beginWithAppLocale(async (tx, appLocale) => {
1023
1333
  const occurrences = await tx<OccurrenceRow[]>`
1024
1334
  SELECT * FROM ai.chat_task_occurrences WHERE id = ${occurrenceId}::uuid AND state = 'queued' LIMIT 1
1025
1335
  `;
@@ -1032,7 +1342,7 @@ export const aiChatTasks = {
1032
1342
  FOR UPDATE OF task
1033
1343
  `;
1034
1344
  if (!tasks[0]) return null;
1035
- const task = await prepareTaskForExecution(tasks[0], tx);
1345
+ const task = await prepareTaskForExecution(tasks[0], tx, appLocale);
1036
1346
  if (!task) return null;
1037
1347
  await tx`
1038
1348
  UPDATE ai.chat_task_occurrences SET task_revision = ${task.revision}::bigint
@@ -1042,8 +1352,8 @@ export const aiChatTasks = {
1042
1352
  });
1043
1353
  },
1044
1354
 
1045
- failOccurrence: async (input: { occurrenceId: string; error: string }): Promise<"failed" | "stale" | "gone"> =>
1046
- sql.begin(async (tx) => {
1355
+ failOccurrence: async (input: { occurrenceId: string; error: string; code: ChatTaskReasonCode }): Promise<"failed" | "stale" | "gone"> =>
1356
+ beginWithAppLocale(async (tx, appLocale) => {
1047
1357
  const task = await loadOccurrenceTask(input.occurrenceId, tx);
1048
1358
  if (!task) return "gone" as const;
1049
1359
  const rows = await tx<{ task_id: string; task_revision: number | bigint }[]>`
@@ -1057,14 +1367,16 @@ export const aiChatTasks = {
1057
1367
  RETURNING occurrence.task_id, occurrence.task_revision
1058
1368
  `;
1059
1369
  if (rows[0]) {
1060
- await deliverOccurrenceResult(tx, task, input.occurrenceId, input.error);
1061
1370
  const revision = await pauseTerminalTaskMandate(task, tx);
1062
1371
  await tx`
1063
1372
  UPDATE ai.chat_tasks
1064
- SET state = 'needs_attention', last_error = ${input.error}, mandate_revision = ${revision},
1373
+ SET state = ${chatTaskReasonState(input.code)}, reason_code = ${input.code}, reason_action = NULL, reason_at = CASE WHEN reason_code = ${input.code} THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${input.error}, mandate_revision = ${revision},
1065
1374
  revision = revision + 1, updated_at = now()
1066
1375
  WHERE id = ${rows[0].task_id}::uuid AND revision = ${rows[0].task_revision}::bigint
1067
1376
  `;
1377
+ const [stopped] = await tx<TaskRow[]>`SELECT * FROM ai.chat_tasks WHERE id = ${task.id}::uuid`;
1378
+ const reason = { code: input.code, at: iso(stopped!.reason_at!) };
1379
+ await deliverOccurrenceResult(tx, task, input.occurrenceId, reasonSentence(task, reason, appLocale), { reason, reasonOnly: true });
1068
1380
  return "failed" as const;
1069
1381
  }
1070
1382
  const stale = await tx<{ stale: boolean }[]>`
@@ -1086,9 +1398,9 @@ export const aiChatTasks = {
1086
1398
  userMessage: Message;
1087
1399
  expectedRevision: number;
1088
1400
  }): Promise<{ delivered: true; conversationId: string; turnId: string } | { delivered: false; reason: "not_found" | "busy" | "stale" }> =>
1089
- sql.begin(async (tx) => {
1401
+ beginWithAppLocale(async (tx, appLocale) => {
1090
1402
  const current = await loadOccurrenceTask(input.occurrenceId, tx);
1091
- const task = current ? await prepareTaskForExecution(current, tx) : null;
1403
+ const task = current ? await prepareTaskForExecution(current, tx, appLocale) : null;
1092
1404
  if (!task) {
1093
1405
  return { delivered: false as const, reason: "not_found" as const };
1094
1406
  }
@@ -1135,9 +1447,13 @@ export const aiChatTasks = {
1135
1447
  `;
1136
1448
  const runConfig: AiChatTurnRunConfig = {
1137
1449
  ...input.runConfig,
1450
+ locale: chatTaskLocale(task.locale, appLocale),
1138
1451
  background: {
1139
1452
  taskId: task.short_id,
1140
1453
  occurrenceId: occurrence.short_id,
1454
+ scheduledFor: iso(occurrence.scheduled_for),
1455
+ trigger: occurrence.trigger,
1456
+ timezone: task.timezone,
1141
1457
  context: contextRows.map((row, index) => ({ seq: index + 1, kind: row.kind, message: row.message })),
1142
1458
  },
1143
1459
  mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
@@ -1182,14 +1498,40 @@ export const aiChatTasks = {
1182
1498
  return { delivered: true as const, conversationId: occurrence.conversation_id, turnId };
1183
1499
  }),
1184
1500
 
1501
+ markNeedsApproval: async (input: { turnId: string; action: string }): Promise<void> => {
1502
+ await sql`UPDATE ai.turns SET run_config = jsonb_set(run_config, '{background,needsApproval}',
1503
+ jsonb_build_object('action', ${input.action.slice(0, 200)}::text))
1504
+ WHERE id = ${input.turnId}::uuid AND run_config ? 'background'
1505
+ AND run_config #> '{background,needsApproval}' IS NULL`;
1506
+ },
1507
+
1185
1508
  finalizeTurn: async (input: {
1186
1509
  turnId: string;
1187
1510
  status: AiTurnStatus;
1188
1511
  error?: string | null;
1189
1512
  }): Promise<{ occurrenceId: string; failed: boolean } | null> =>
1190
- sql.begin(async (tx) => {
1513
+ beginWithAppLocale(async (tx, appLocale) => {
1191
1514
  const task = await loadTurnTask(input.turnId, tx);
1515
+ // A plain read: the stop line claims the turn itself, after it locked the chat as archiving does.
1516
+ const [turn] = await tx<StoppedTurn[]>`SELECT id, conversation_id, cancellation_reason, run_config FROM ai.turns
1517
+ WHERE id = ${input.turnId}::uuid`;
1518
+ if (!turn) return null;
1519
+ if (turn.cancellation_reason === "routine_paused" || turn.cancellation_reason === "routine_deleted") {
1520
+ const text = await deliverStoppedTurn(
1521
+ tx,
1522
+ turn,
1523
+ turn.cancellation_reason === "routine_paused" ? "paused" : "deleted",
1524
+ task,
1525
+ appLocale,
1526
+ );
1527
+ const [occurrence] = await tx<{ id: string }[]>`UPDATE ai.chat_task_occurrences
1528
+ SET state = 'stopped', error = NULL, result_text = COALESCE(${text}, result_text), completed_at = now(), delivered_at = now()
1529
+ WHERE turn_id = ${input.turnId}::uuid AND state = 'running' RETURNING id`;
1530
+ return occurrence ? { occurrenceId: occurrence.id, failed: false } : null;
1531
+ }
1192
1532
  if (!task) return null;
1533
+ const needsApproval = turn.run_config.background?.needsApproval;
1534
+ const approvalError = needsApproval ? `NEEDS_APPROVAL: ${needsApproval.action} requires approval for every call` : null;
1193
1535
  const rows = await tx<
1194
1536
  {
1195
1537
  occurrence_id: string;
@@ -1201,11 +1543,15 @@ export const aiChatTasks = {
1201
1543
  task_revision: number | bigint;
1202
1544
  current_revision: number | bigint;
1203
1545
  turn_error: string | null;
1546
+ needs_approval: boolean;
1547
+ completed_at: Date | string;
1204
1548
  }[]
1205
1549
  >`
1206
1550
  UPDATE ai.chat_task_occurrences occurrence
1207
- SET state = ${input.status === "completed" ? "completed" : "failed"},
1551
+ SET state = CASE WHEN ${approvalError}::text IS NOT NULL AND occurrence.task_revision = task.revision THEN 'failed'
1552
+ ELSE ${input.status === "completed" ? "completed" : "failed"} END,
1208
1553
  error = CASE
1554
+ WHEN ${approvalError}::text IS NOT NULL AND occurrence.task_revision = task.revision THEN ${approvalError}
1209
1555
  WHEN ${input.status} = 'completed' THEN NULL
1210
1556
  ELSE COALESCE(${input.error ?? null}, turn.error, ${`Scheduled turn ${input.status}`})
1211
1557
  END,
@@ -1214,10 +1560,12 @@ export const aiChatTasks = {
1214
1560
  WHERE occurrence.task_id = task.id AND occurrence.turn_id = ${input.turnId}::uuid AND turn.id = occurrence.turn_id
1215
1561
  AND occurrence.state = 'running'
1216
1562
  RETURNING occurrence.id AS occurrence_id, occurrence.task_id, task.schedule_kind, occurrence.trigger,
1217
- occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error
1563
+ occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error, (${approvalError}::text IS NOT NULL AND occurrence.task_revision = task.revision) AS needs_approval, occurrence.completed_at
1218
1564
  `;
1219
1565
  const row = rows[0];
1220
1566
  if (!row) return null;
1567
+ const failed = row.needs_approval || input.status !== "completed";
1568
+ const failureCode = row.needs_approval ? "needs_approval" : "failed";
1221
1569
  const responses = await tx<{ message: Message }[]>`
1222
1570
  SELECT message FROM ai.task_messages WHERE loop_id = ${input.turnId} AND role = 'assistant' ORDER BY seq DESC
1223
1571
  `;
@@ -1232,51 +1580,63 @@ export const aiChatTasks = {
1232
1580
  .join("\n")
1233
1581
  .trim();
1234
1582
  })
1235
- .find(Boolean) ||
1236
- input.error ||
1237
- row.turn_error ||
1238
- (input.status === "completed"
1239
- ? "Background task completed."
1240
- : "Background task could not finish. Please review this run in the chat.");
1241
- await deliverOccurrenceResult(tx, task, row.occurrence_id, resultText);
1583
+ .find(Boolean) || (!failed ? "Background task completed." : "");
1242
1584
  const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
1243
1585
  const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
1244
- if (
1245
- unchangedSinceStart &&
1246
- input.status === "completed" &&
1247
- row.schedule_kind === "once" &&
1248
- row.trigger === "scheduled" &&
1249
- currentOnceSlot
1250
- ) {
1586
+ if (unchangedSinceStart && !failed && row.schedule_kind === "once" && row.trigger === "scheduled" && currentOnceSlot) {
1251
1587
  const revision = await pauseTerminalTaskMandate(task, tx);
1252
1588
  await tx`
1253
1589
  UPDATE ai.chat_tasks
1254
- SET state = 'completed', last_error = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1590
+ SET state = 'completed', last_error = NULL, reason_code = NULL, reason_action = NULL, reason_at = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1255
1591
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1256
1592
  `;
1257
- } else if (unchangedSinceStart && input.status !== "completed") {
1258
- const error = input.error || row.turn_error || `Scheduled turn ${input.status}`;
1593
+ } else if (unchangedSinceStart && failed) {
1594
+ const error = (row.needs_approval ? approvalError : null) || input.error || row.turn_error || `Scheduled turn ${input.status}`;
1259
1595
  const revision = await pauseTerminalTaskMandate(task, tx);
1260
1596
  await tx`
1261
1597
  UPDATE ai.chat_tasks
1262
- SET state = 'needs_attention', last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1598
+ SET state = ${chatTaskReasonState(failureCode)}, reason_code = ${failureCode},
1599
+ reason_action = ${row.needs_approval ? needsApproval!.action : null},
1600
+ reason_at = CASE WHEN reason_code = ${failureCode} THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1263
1601
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1264
1602
  `;
1265
1603
  }
1266
- return { occurrenceId: row.occurrence_id, failed: input.status !== "completed" };
1604
+ const [current] = await tx<TaskRow[]>`SELECT * FROM ai.chat_tasks WHERE id = ${task.id}::uuid`;
1605
+ const reason: ChatTaskReason | null = failed
1606
+ ? {
1607
+ code: failureCode,
1608
+ at: current?.reason_code === failureCode && current.reason_at ? iso(current.reason_at) : iso(row.completed_at),
1609
+ ...(row.needs_approval ? { action: needsApproval!.action } : {}),
1610
+ }
1611
+ : null;
1612
+ await deliverOccurrenceResult(
1613
+ tx,
1614
+ task,
1615
+ row.occurrence_id,
1616
+ resultText || (reason ? reasonSentence(task, reason, appLocale) : ""),
1617
+ reason ? { reason, ...(resultText ? {} : { reasonOnly: true as const }) } : {},
1618
+ );
1619
+ return { occurrenceId: row.occurrence_id, failed };
1267
1620
  }),
1268
1621
 
1269
1622
  listTerminalRunningTurns: async (limit = 100): Promise<Array<{ turnId: string; status: "completed" | "failed" | "aborted" }>> => {
1270
1623
  const rows = await sql<{ turn_id: string; status: "completed" | "failed" | "aborted" }[]>`
1271
- SELECT occurrence.turn_id, turn.status
1272
- FROM ai.chat_task_occurrences occurrence
1273
- JOIN ai.chat_tasks task ON task.id = occurrence.task_id
1274
- JOIN ai.conversations conversation ON conversation.id = task.conversation_id
1275
- JOIN ai.turns turn ON turn.id = occurrence.turn_id
1276
- WHERE occurrence.state = 'running'
1277
- AND turn.status IN ('completed', 'failed', 'aborted')
1278
- ORDER BY turn.completed_at, occurrence.id
1279
- LIMIT ${Math.min(Math.max(limit, 1), 500)}
1624
+ SELECT turn_id, status FROM (
1625
+ (SELECT occurrence.turn_id, turn.status, turn.completed_at
1626
+ FROM ai.chat_task_occurrences occurrence JOIN ai.turns turn ON turn.id = occurrence.turn_id
1627
+ WHERE occurrence.state = 'running' AND turn.status IN ('completed', 'failed', 'aborted')
1628
+ ORDER BY turn.completed_at, occurrence.turn_id LIMIT ${Math.min(Math.max(limit, 1), 500)})
1629
+ UNION
1630
+ (SELECT turn.id AS turn_id, turn.status, turn.completed_at FROM ai.turns turn
1631
+ -- A paused run whose routine was deleted before its line went out has no occurrence left either.
1632
+ WHERE turn.cancellation_reason IN ('routine_deleted', 'routine_paused') AND turn.run_config ? 'background'
1633
+ AND turn.status IN ('completed', 'failed', 'aborted')
1634
+ AND NOT EXISTS (SELECT 1 FROM ai.chat_task_occurrences occurrence WHERE occurrence.turn_id = turn.id)
1635
+ AND turn.completed_at >= now() - interval '1 day'
1636
+ AND turn.run_config #>> '{background,stopDeliveredAt}' IS NULL
1637
+ ORDER BY turn.completed_at, turn.id LIMIT ${Math.min(Math.max(limit, 1), 500)})
1638
+ ) pending
1639
+ ORDER BY completed_at, turn_id LIMIT ${Math.min(Math.max(limit, 1), 500)}
1280
1640
  `;
1281
1641
  return rows.map((row) => ({ turnId: row.turn_id, status: row.status }));
1282
1642
  },