@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -16,14 +16,38 @@ import {
16
16
  updateMandatePolicy,
17
17
  validateMandateIssueAuthority,
18
18
  } from "../services/mandates";
19
- import { parsePgJsonValue } from "../services/postgres";
19
+ import { parsePgJsonValue, toPgUuidArray } from "../services/postgres";
20
+ import { coreSettings } from "../services/settings/api";
21
+ import { canonicalLocale } from "../shared/locale";
22
+ import { chatTaskName } from "./chat-task-contracts";
23
+ import {
24
+ type ChatTaskReason,
25
+ type ChatTaskReasonCode,
26
+ chatTaskLocale,
27
+ chatTaskNeedsNewTime,
28
+ chatTaskReasonSentence,
29
+ chatTaskReasonState,
30
+ chatTaskStopSentence,
31
+ } from "./chat-task-reasons";
32
+ import { buildBlocksFromMessages } from "./protocol";
20
33
  import { withAiShortIdForDb } from "./short-id";
21
34
  import { aiConversations } from "./store";
35
+ import { toolOutcome, toolWording } from "./tool-wording";
36
+ import { finishAiTurnAbort } from "./turn-abort";
22
37
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
23
38
 
39
+ /** Run history and transcripts are operational evidence; 90 days matches capability execution history.
40
+ * Fixed platform constant, not configurable. */
41
+ export const AI_CHAT_TASK_RUN_RETENTION_DAYS = 90;
42
+
24
43
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
25
44
  export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
26
45
  export type AiChatTask = {
46
+ title: string | null;
47
+ name: string;
48
+ locale: string | null;
49
+ reasonCode: ChatTaskReasonCode | null;
50
+ reasonAt: string | null;
27
51
  id: string;
28
52
  shortId: string;
29
53
  chatId: string;
@@ -43,7 +67,7 @@ export type AiChatTask = {
43
67
  updatedAt: string;
44
68
  };
45
69
 
46
- export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed";
70
+ export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed" | "stopped";
47
71
  export type AiChatTaskOccurrence = {
48
72
  id: string;
49
73
  shortId: string;
@@ -60,6 +84,10 @@ export type AiChatTaskOccurrence = {
60
84
  };
61
85
 
62
86
  type TaskRow = {
87
+ title: string | null;
88
+ locale: string | null;
89
+ reason_code: ChatTaskReasonCode | null;
90
+ reason_at: Date | string | null;
63
91
  id: string;
64
92
  short_id: string;
65
93
  conversation_id: string;
@@ -218,17 +246,19 @@ const taskMandatePolicy = (mandate: TaskMandateRow): MandatePolicyV1 | null => {
218
246
  };
219
247
  const stopTaskAdmission = async (
220
248
  task: TaskRow,
221
- state: "paused" | "needs_attention",
249
+ code: ChatTaskReasonCode,
222
250
  reason: string | null,
223
251
  db: SQL,
224
- pausedMandateRevision?: number,
252
+ appLocale: string | undefined,
253
+ pausedMandateRevision?: number | null,
225
254
  ): Promise<null> => {
226
- await db`
255
+ const state = chatTaskReasonState(code);
256
+ const [stopped] = await db<TaskRow[]>`
227
257
  UPDATE ai.chat_tasks
228
- SET state = ${state}, last_error = ${reason},
258
+ SET state = ${state}, last_error = ${reason}, reason_code = ${code}, reason_at = now(),
229
259
  mandate_revision = COALESCE(${pausedMandateRevision ?? null}::bigint, mandate_revision),
230
260
  revision = revision + 1, updated_at = now()
231
- WHERE id = ${task.id}::uuid AND state = 'active'
261
+ WHERE id = ${task.id}::uuid AND state = ${task.state} RETURNING *
232
262
  `;
233
263
  if (state === "needs_attention" && reason) {
234
264
  const occurrences = await db<{ id: string }[]>`
@@ -248,26 +278,45 @@ const stopTaskAdmission = async (
248
278
  );
249
279
  occurrences.push(...failed);
250
280
  }
251
- for (const occurrence of occurrences) await deliverOccurrenceResult(db, task, occurrence.id, reason);
281
+ for (const occurrence of occurrences) {
282
+ const taskReason = { code, at: iso(stopped!.reason_at!) };
283
+ await deliverOccurrenceResult(db, task, occurrence.id, reasonSentence(task, taskReason, appLocale), {
284
+ reason: taskReason,
285
+ reasonOnly: true,
286
+ });
287
+ }
252
288
  }
253
289
  return null;
254
290
  };
255
291
  /** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
256
- const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow | null> => {
292
+ const prepareTaskForExecution = async (input: TaskRow, db: SQL, appLocale: string | undefined): Promise<TaskRow | null> => {
257
293
  if (input.state !== "active") return null;
258
294
  const task = input;
259
295
  const mandate = await loadTaskMandate(task, db);
260
296
  const policy = mandate ? taskMandatePolicy(mandate) : null;
261
297
  const [sponsor] = await db<{ id: string }[]>`
262
- SELECT sponsor.id FROM auth.users sponsor
263
- JOIN ai.conversations conversation ON conversation.created_by_user_id = sponsor.id
264
- WHERE sponsor.id = ${task.sponsor_user_id}::uuid AND conversation.id = ${task.conversation_id}::uuid
265
- AND (sponsor.account_expires IS NULL OR sponsor.account_expires > now()) AND conversation.archived_at IS NULL
266
- FOR SHARE OF sponsor
267
- FOR UPDATE OF conversation
268
- `;
298
+ SELECT id FROM auth.users WHERE id = ${task.sponsor_user_id}::uuid
299
+ AND (account_expires IS NULL OR account_expires > now()) FOR SHARE`;
300
+ const [conversation] = await db<{ archived_at: Date | null; created_by_user_id: string }[]>`
301
+ SELECT archived_at, created_by_user_id FROM ai.conversations WHERE id = ${task.conversation_id}::uuid FOR UPDATE`;
302
+ if (!sponsor)
303
+ return stopTaskAdmission(
304
+ task,
305
+ "owner_inactive",
306
+ "Scheduled task sponsor is unavailable",
307
+ db,
308
+ appLocale,
309
+ await pauseTerminalTaskMandate(task, db),
310
+ );
311
+ const archived = () =>
312
+ pauseTerminalTaskMandate(task, db).then((revision) =>
313
+ stopTaskAdmission(task, "chat_archived", "Scheduled task chat is archived or unavailable", db, appLocale, revision),
314
+ );
315
+ if (!conversation) return archived();
316
+ // Authority comes before the archive: a stop or change of what the routine may do must not turn into an archive
317
+ // pause that restoring the chat would quietly undo.
269
318
  if (
270
- !sponsor ||
319
+ conversation.created_by_user_id !== task.sponsor_user_id ||
271
320
  !mandate ||
272
321
  !taskMandateMatches(task, mandate) ||
273
322
  !mandate.confirmed_at ||
@@ -276,12 +325,13 @@ const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow
276
325
  !isChatTaskMandatePolicy(policy) ||
277
326
  mandate.state === "revoked"
278
327
  ) {
279
- return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate or sponsor is unavailable", db);
328
+ return stopTaskAdmission(task, "rights_changed", "Scheduled task mandate or sponsor is unavailable", db, appLocale);
280
329
  }
281
- if (mandate.state === "paused") return stopTaskAdmission(task, "paused", null, db, Number(mandate.revision));
330
+ if (mandate.state === "paused") return stopTaskAdmission(task, "paused", null, db, appLocale, Number(mandate.revision));
282
331
  if (task.mandate_revision === null || Number(task.mandate_revision) !== Number(mandate.revision)) {
283
- return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate revision changed", db);
332
+ return stopTaskAdmission(task, "rights_changed", "Scheduled task mandate revision changed", db, appLocale);
284
333
  }
334
+ if (conversation.archived_at) return archived();
285
335
  return task;
286
336
  };
287
337
  /** Terminal accounting may narrow existing authority, but must never create or resume it. */
@@ -299,12 +349,23 @@ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number
299
349
  const taskFingerprint = (input: {
300
350
  chatId: string;
301
351
  prompt: string;
352
+ title?: string;
302
353
  schedule: AiChatTaskSchedule;
303
354
  timezone: string;
304
355
  grants?: TaskGrant[];
305
356
  }): string =>
306
357
  new Bun.CryptoHasher("sha256")
307
- .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone, input.grants ?? []]))
358
+ .update(
359
+ // Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
360
+ JSON.stringify([
361
+ input.chatId,
362
+ input.prompt.trim(),
363
+ input.schedule,
364
+ input.timezone,
365
+ input.grants ?? [],
366
+ ...(input.title?.trim() ? [input.title.trim()] : []),
367
+ ]),
368
+ )
308
369
  .digest("hex");
309
370
  const taskSelect = () => sql`
310
371
  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
@@ -321,6 +382,11 @@ const taskGrants = (row: TaskRow): TaskGrant[] => {
321
382
  }
322
383
  };
323
384
  const toTask = (row: TaskRow): AiChatTask => ({
385
+ title: row.title,
386
+ name: chatTaskName({ title: row.title, chatTitle: row.conversation_title, prompt: row.prompt }),
387
+ locale: row.locale,
388
+ reasonCode: row.reason_code,
389
+ reasonAt: nullableIso(row.reason_at),
324
390
  id: row.id,
325
391
  shortId: row.short_id,
326
392
  chatId: row.conversation_short_id,
@@ -356,13 +422,13 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
356
422
  });
357
423
 
358
424
  const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
359
- sql.begin(async (tx) => {
425
+ beginWithAppLocale(async (tx, appLocale) => {
360
426
  const [task] = await tx<TaskRow[]>`
361
427
  ${taskSelect()}
362
428
  WHERE task.id = ${taskId}::uuid
363
429
  FOR UPDATE OF task
364
430
  `;
365
- const prepared = task ? await prepareTaskForExecution(task, tx) : null;
431
+ const prepared = task ? await prepareTaskForExecution(task, tx, appLocale) : null;
366
432
  return prepared ? toTask(prepared) : null;
367
433
  });
368
434
  const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
@@ -388,8 +454,26 @@ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> =>
388
454
  return task ?? null;
389
455
  };
390
456
 
457
+ /**
458
+ * A transaction that may word a routine's reason or stop line. The app locale is read first: on a cache miss the
459
+ * settings read takes another pooled connection, which must never wait inside a transaction holding task locks.
460
+ */
461
+ const beginWithAppLocale = async <T>(run: (tx: SQL, appLocale: string | undefined) => Promise<T>): Promise<T> => {
462
+ const appLocale = await coreSettings.get<string>("app.locale");
463
+ return sql.begin((tx) => run(tx, appLocale));
464
+ };
465
+
391
466
  /** Called in the occurrence terminal transition transaction, so delivery is exactly once. */
392
- const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: string, resultText: string): Promise<void> => {
467
+ const reasonSentence = (task: TaskRow, reason: ChatTaskReason, appLocale: string | undefined): string =>
468
+ chatTaskReasonSentence(reason, { locale: chatTaskLocale(task.locale, appLocale), timeZone: task.timezone });
469
+
470
+ const deliverOccurrenceResult = async (
471
+ db: SQL,
472
+ task: TaskRow,
473
+ occurrenceId: string,
474
+ resultText: string,
475
+ extra: Pick<NonNullable<NonNullable<AiStoredMessage["meta"]>["scheduledTask"]>, "reason" | "reasonOnly" | "stop"> = {},
476
+ ): Promise<void> => {
393
477
  const [occurrence] = await db<OccurrenceRow[]>`
394
478
  UPDATE ai.chat_task_occurrences SET result_text = ${resultText}, delivered_at = now()
395
479
  WHERE id = ${occurrenceId}::uuid AND delivered_at IS NULL RETURNING *
@@ -406,6 +490,7 @@ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: str
406
490
  occurrenceId: occurrence.short_id,
407
491
  scheduledFor: iso(occurrence.scheduled_for),
408
492
  trigger: occurrence.trigger,
493
+ ...extra,
409
494
  },
410
495
  };
411
496
  await withAiShortIdForDb(
@@ -421,6 +506,98 @@ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: str
421
506
  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`;
422
507
  };
423
508
 
509
+ type StopCause = "paused" | "deleted";
510
+ const withTaskAbortTransaction = async <T>(
511
+ run: (tx: SQL, stop: (task: TaskRow, cause: StopCause) => Promise<void>) => Promise<T>,
512
+ ): Promise<T> => {
513
+ const aborts: Array<{
514
+ input: { conversationId: string; turnId: string };
515
+ request: Awaited<ReturnType<typeof aiConversations.requestTurnAbort>>;
516
+ }> = [];
517
+ const result = await beginWithAppLocale(async (tx, appLocale) =>
518
+ run(tx, async (task, cause) => {
519
+ const turns = await tx<{ turn_id: string; scheduled_for: Date | string; trigger: "scheduled" | "manual" }[]>`
520
+ SELECT turn_id, scheduled_for, trigger FROM ai.chat_task_occurrences
521
+ WHERE task_id = ${task.id}::uuid AND state = 'running' AND turn_id IS NOT NULL`;
522
+ for (const turn of turns) {
523
+ const input = { conversationId: task.conversation_id, turnId: turn.turn_id };
524
+ // Keep the run's delivery coordinates even when its task/occurrence is about to cascade away.
525
+ const locale = chatTaskLocale(task.locale, appLocale);
526
+ await tx`UPDATE ai.turns SET run_config = jsonb_set(
527
+ jsonb_set(run_config, '{locale}', to_jsonb(COALESCE(run_config->>'locale', ${locale}))),
528
+ '{background}', jsonb_build_object('scheduledFor', ${iso(turn.scheduled_for)}::text,
529
+ 'trigger', ${turn.trigger}::text, 'timezone', ${task.timezone}::text) || (run_config->'background'))
530
+ WHERE id = ${turn.turn_id}::uuid AND run_config ? 'background'`;
531
+ const request = await aiConversations.requestTurnAbort(
532
+ { ...input, reason: cause === "paused" ? "routine_paused" : "routine_deleted" },
533
+ { db: tx },
534
+ );
535
+ aborts.push({ input, request });
536
+ }
537
+ }),
538
+ );
539
+ for (const { input, request } of aborts) {
540
+ await finishAiTurnAbort(input, request);
541
+ if (request.found && request.ownerless) await aiChatTasks.finalizeTurn({ turnId: input.turnId, status: "aborted" });
542
+ }
543
+ return result;
544
+ };
545
+
546
+ type StoppedTurn = { id: string; conversation_id: string; cancellation_reason: string | null; run_config: AiChatTurnRunConfig };
547
+ /** The turn lock and persisted receipt survive deletion of the task and its occurrences. */
548
+ /** Returns the stop line once; `null` when another finalization already claimed it. */
549
+ const deliverStoppedTurn = async (
550
+ db: SQL,
551
+ turn: StoppedTurn,
552
+ cause: StopCause,
553
+ task: TaskRow | null,
554
+ appLocale: string | undefined,
555
+ ): Promise<string | null> => {
556
+ const config = turn.run_config;
557
+ if (!config.background) return null;
558
+ // Chat before turn, the order archiving locks them in, so the two never wait on each other.
559
+ const [conversation] = await db<{ archived_at: Date | null }[]>`
560
+ SELECT archived_at FROM ai.conversations WHERE id = ${turn.conversation_id}::uuid FOR UPDATE`;
561
+ const [claimed] = await db`UPDATE ai.turns SET run_config = jsonb_set(run_config, '{background,stopDeliveredAt}', to_jsonb(now()::text))
562
+ WHERE id = ${turn.id}::uuid AND run_config #>> '{background,stopDeliveredAt}' IS NULL RETURNING id`;
563
+ if (!claimed) return null;
564
+ const locale = chatTaskLocale(config.locale ?? task?.locale, appLocale);
565
+ const messages = await db<{ seq: number; message: Message; meta: AiStoredMessage["meta"] }[]>`
566
+ SELECT seq, message, meta FROM ai.task_messages WHERE loop_id = ${turn.id} ORDER BY seq`;
567
+ const done = buildBlocksFromMessages(messages).flatMap((block) => {
568
+ if (block.kind !== "tool" || block.status !== "completed" || block.isError) return [];
569
+ // Web reads have chat wording too, but do not change anything for a stop receipt.
570
+ if (block.name === "web_extract") return [];
571
+ if (block.result && typeof block.result === "object" && "ok" in block.result && block.result.ok === false) return [];
572
+ const wording = toolWording(block, locale);
573
+ if (!wording) return [];
574
+ // An Action without a done sentence still changed something; its title names it instead of dropping it.
575
+ return [toolOutcome(block, "done", locale, config.background?.timezone ?? task?.timezone ?? "UTC") ?? wording.title];
576
+ });
577
+ const text = chatTaskStopSentence(done, locale);
578
+ if (!conversation || conversation.archived_at) return text;
579
+ const scheduledTask = {
580
+ taskId: config.background.taskId,
581
+ occurrenceId: config.background.occurrenceId,
582
+ scheduledFor: config.background.scheduledFor ?? "",
583
+ trigger: config.background.trigger ?? "scheduled",
584
+ stop: { cause, done },
585
+ };
586
+ const message: Message = { role: "assistant", content: [{ type: "text", text }] };
587
+ await withAiShortIdForDb(
588
+ db,
589
+ "idx_ai_messages_conversation_short_id",
590
+ (attempt, shortId) => attempt`
591
+ INSERT INTO ai.messages(short_id, conversation_id, seq, kind, role, message, search_text, meta)
592
+ VALUES (${shortId}, ${turn.conversation_id}::uuid,
593
+ (SELECT COALESCE(MAX(seq),0)+1 FROM ai.messages WHERE conversation_id = ${turn.conversation_id}::uuid),
594
+ 'message', 'assistant', ${JSON.stringify(message)}::text::jsonb, ${text}, ${JSON.stringify({ scheduledTask })}::text::jsonb)`,
595
+ );
596
+ await db`UPDATE ai.conversations SET done = NULL, last_used_at = now(), updated_at = now(), background_received_at = now()
597
+ WHERE id = ${turn.conversation_id}::uuid`;
598
+ return text;
599
+ };
600
+
424
601
  export const aiChatTasks = {
425
602
  listActivities: async (input: { userId: string; limit?: number; offset?: number }) => {
426
603
  const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
@@ -548,6 +725,8 @@ export const aiChatTasks = {
548
725
  userId: string;
549
726
  chatId: string;
550
727
  prompt: string;
728
+ title?: string;
729
+ locale?: string;
551
730
  schedule: AiChatTaskSchedule;
552
731
  timezone: string;
553
732
  grants?: TaskGrant[];
@@ -599,11 +778,11 @@ export const aiChatTasks = {
599
778
  "ai_chat_tasks_short_id_unique",
600
779
  (attempt, shortId) => attempt<TaskRow[]>`
601
780
  INSERT INTO ai.chat_tasks (
602
- id, short_id, conversation_id, sponsor_user_id, prompt, schedule_kind, run_at, cron, timezone,
781
+ id, short_id, conversation_id, sponsor_user_id, prompt, title, locale, schedule_kind, run_at, cron, timezone,
603
782
  mandate_id, mandate_revision, idempotency_key, idempotency_fingerprint
604
783
  )
605
784
  VALUES (
606
- ${taskId}::uuid, ${shortId}, ${conversation.id}::uuid, ${input.userId}::uuid, ${input.prompt.trim()}, ${input.schedule.kind},
785
+ ${taskId}::uuid, ${shortId}, ${conversation.id}::uuid, ${input.userId}::uuid, ${input.prompt.trim()}, ${input.title?.trim() ?? null}, ${canonicalLocale(input.locale) ?? null}, ${input.schedule.kind},
607
786
  ${input.schedule.kind === "once" ? input.schedule.runAt : null}::timestamptz,
608
787
  ${input.schedule.kind === "cron" ? input.schedule.cron : null}, ${input.timezone},
609
788
  ${mandate.data.id}::uuid, ${mandate.data.revision}, ${scopedKey}, ${fingerprint}
@@ -654,11 +833,12 @@ export const aiChatTasks = {
654
833
  userId: string;
655
834
  taskId: string;
656
835
  prompt?: string;
836
+ title?: string;
657
837
  grants?: TaskGrant[];
658
838
  schedule?: AiChatTaskSchedule;
659
839
  timezone?: string;
660
840
  }): Promise<AiChatTask | null> =>
661
- sql.begin(async (tx) => {
841
+ beginWithAppLocale(async (tx, appLocale) => {
662
842
  const rows = await tx<TaskRow[]>`
663
843
  ${taskSelect()}
664
844
  WHERE task.sponsor_user_id = ${input.userId}::uuid
@@ -669,7 +849,7 @@ export const aiChatTasks = {
669
849
  if (!current) return null;
670
850
  requireTaskMandate(current);
671
851
  const wasActive = current.state === "active";
672
- const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
852
+ const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx, appLocale));
673
853
  if (authorityStopped) {
674
854
  [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
675
855
  if (!current) return null;
@@ -698,17 +878,27 @@ export const aiChatTasks = {
698
878
  if (!updated.ok) throw mandateError("update", updated);
699
879
  updatedMandateRevision = updated.data.revision;
700
880
  }
701
- if (!promptChanged && !scheduleChanged && grants === undefined) return toTask(current);
881
+ const titleChanged = input.title !== undefined && input.title.trim() !== current.title;
882
+ if (!promptChanged && !scheduleChanged && grants === undefined) {
883
+ if (!titleChanged) return toTask(current);
884
+ // A new name changes nothing about when or how the routine runs: state, reason and revision stay.
885
+ const [renamed] = await tx<TaskRow[]>`
886
+ UPDATE ai.chat_tasks task SET title = ${input.title!.trim()}, updated_at = now()
887
+ FROM ai.conversations conversation
888
+ WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
889
+ RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title`;
890
+ return renamed ? toTask({ ...renamed, mandate_policy: current.mandate_policy }) : null;
891
+ }
702
892
  await tx`
703
893
  UPDATE ai.chat_tasks task
704
- SET mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
894
+ SET title = ${input.title?.trim() ?? current.title}, mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
705
895
  schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
706
896
  run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
707
897
  cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
708
898
  timezone = ${input.timezone ?? current.timezone},
709
899
  state = CASE
710
900
  WHEN ${authorityStopped} THEN task.state
711
- WHEN task.state = 'paused' THEN 'paused'
901
+ WHEN task.state = 'paused' THEN ${chatTaskReasonState("paused")}
712
902
  WHEN task.state = 'completed' AND ${scheduleChanged} = false THEN 'completed'
713
903
  ELSE 'active'
714
904
  END,
@@ -717,6 +907,8 @@ export const aiChatTasks = {
717
907
  THEN task.last_error
718
908
  ELSE NULL
719
909
  END,
910
+ reason_code = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_code ELSE NULL END,
911
+ reason_at = CASE WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false) THEN task.reason_at ELSE NULL END,
720
912
  revision = task.revision + 1, updated_at = now()
721
913
  WHERE id = ${current.id}::uuid
722
914
  `;
@@ -739,8 +931,95 @@ export const aiChatTasks = {
739
931
  return toTask(next);
740
932
  }),
741
933
 
742
- setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
934
+ pauseForArchivedChat: async (input: { conversationId: string; userId?: string }): Promise<void> => {
935
+ await beginWithAppLocale(async (tx, appLocale) => {
936
+ const tasks = await tx<TaskRow[]>`${taskSelect()}
937
+ WHERE task.conversation_id = ${input.conversationId}::uuid AND task.state = 'active'
938
+ AND (${input.userId ?? null}::uuid IS NULL OR task.sponsor_user_id = ${input.userId ?? null}::uuid)
939
+ ORDER BY task.id FOR UPDATE OF task`;
940
+ if (!tasks.length) return;
941
+ // Locks in admission order (tasks, then chat). A restore that committed first leaves its routines running;
942
+ // one that comes later waits for this pause and then resumes them.
943
+ const [archived] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
944
+ AND archived_at IS NOT NULL FOR UPDATE`;
945
+ if (!archived) return;
946
+ // Admission decides the reason: an archive pause only for a routine whose authority is still intact.
947
+ for (const task of tasks) await prepareTaskForExecution(task, tx, appLocale);
948
+ });
949
+ },
950
+
951
+ resumeForRestoredChat: async (input: { conversationId: string; userId?: string; resume: boolean }): Promise<void> => {
952
+ const tasks = await sql<TaskRow[]>`${taskSelect()}
953
+ WHERE task.conversation_id = ${input.conversationId}::uuid AND task.reason_code = 'chat_archived'
954
+ AND (${input.userId ?? null}::uuid IS NULL OR task.sponsor_user_id = ${input.userId ?? null}::uuid)`;
955
+ for (const task of tasks) {
956
+ try {
957
+ if (
958
+ input.resume &&
959
+ (await aiChatTasks.setState({
960
+ userId: task.sponsor_user_id,
961
+ taskId: task.short_id,
962
+ state: "active",
963
+ expectedReason: "chat_archived",
964
+ }))
965
+ )
966
+ continue;
967
+ await sql.begin(async (tx) => {
968
+ const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${task.id}::uuid FOR UPDATE OF task`;
969
+ // Archived again meanwhile: the routine stays paused for that reason.
970
+ const [restored] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
971
+ AND archived_at IS NULL FOR SHARE`;
972
+ if (current?.reason_code === "chat_archived" && restored)
973
+ await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', updated_at = now() WHERE id = ${current.id}::uuid`;
974
+ });
975
+ } catch (error) {
976
+ if (!(error instanceof AiChatTaskAuthorityError)) throw error;
977
+ await beginWithAppLocale(async (tx, appLocale) => {
978
+ const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${task.id}::uuid FOR UPDATE OF task`;
979
+ // Archived again meanwhile: the routine stays paused for that reason.
980
+ const [restored] = await tx`SELECT 1 FROM ai.conversations WHERE id = ${input.conversationId}::uuid
981
+ AND archived_at IS NULL FOR SHARE`;
982
+ if (current?.reason_code === "chat_archived" && restored)
983
+ await stopTaskAdmission(current, "rights_changed", error.message, tx, appLocale);
984
+ });
985
+ }
986
+ }
987
+ },
988
+
989
+ /** Run before a restore: the fresh timestamp keeps the recovery backstop away from routines this restore handles. */
990
+ markRestoreStarted: async (input: { conversationId: string; userId?: string }): Promise<void> => {
991
+ await sql`UPDATE ai.chat_tasks SET updated_at = now()
992
+ WHERE conversation_id = ${input.conversationId}::uuid AND reason_code = 'chat_archived'
993
+ AND (${input.userId ?? null}::uuid IS NULL OR sponsor_user_id = ${input.userId ?? null}::uuid)`;
994
+ },
995
+
996
+ /**
997
+ * Recovery backstop for a restore whose routine step failed: archive pauses of restored chats become plain pauses,
998
+ * so they never name an archive that has ended. It never resumes; the grace leaves a restore in progress alone.
999
+ */
1000
+ relabelRestoredArchivePauses: async (input: { olderThanMs?: number; limit?: number } = {}): Promise<number> => {
1001
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
1002
+ const graceSeconds = Math.max(input.olderThanMs ?? 5 * 60_000, 0) / 1000;
743
1003
  return sql.begin(async (tx) => {
1004
+ const rows = await tx<{ id: string }[]>`
1005
+ SELECT task.id FROM ai.chat_tasks task
1006
+ JOIN ai.conversations conversation ON conversation.id = task.conversation_id
1007
+ WHERE task.reason_code = 'chat_archived' AND conversation.archived_at IS NULL
1008
+ AND task.updated_at < now() - make_interval(secs => ${graceSeconds}::double precision)
1009
+ ORDER BY task.id LIMIT ${limit}
1010
+ FOR UPDATE OF task SKIP LOCKED FOR SHARE OF conversation SKIP LOCKED`;
1011
+ for (const row of rows) await tx`UPDATE ai.chat_tasks SET reason_code = 'paused', updated_at = now() WHERE id = ${row.id}::uuid`;
1012
+ return rows.length;
1013
+ });
1014
+ },
1015
+
1016
+ setState: async (input: {
1017
+ userId: string;
1018
+ taskId: string;
1019
+ state: "active" | "paused";
1020
+ expectedReason?: ChatTaskReasonCode;
1021
+ }): Promise<AiChatTask | null> => {
1022
+ return withTaskAbortTransaction(async (tx, stop) => {
744
1023
  const rows = await tx<TaskRow[]>`
745
1024
  ${taskSelect()}
746
1025
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
@@ -748,11 +1027,18 @@ export const aiChatTasks = {
748
1027
  `;
749
1028
  let current = rows[0];
750
1029
  if (!current) return null;
1030
+ if (input.expectedReason && current.reason_code !== input.expectedReason) return null;
1031
+ // An automatic resume continues only the authority it paused; any change since then needs a confirmation.
1032
+ if (input.expectedReason) {
1033
+ const paused = await loadTaskMandate(current, tx);
1034
+ if (!paused || current.mandate_revision === null || Number(paused.revision) !== Number(current.mandate_revision))
1035
+ throw new AiChatTaskAuthorityError("Scheduled task mandate changed while the routine was paused");
1036
+ }
751
1037
  requireTaskMandate(current);
752
1038
  const allowed =
753
1039
  current.state === input.state ||
754
1040
  (current.state === "active" && input.state === "paused") ||
755
- (current.state === "paused" && input.state === "active") ||
1041
+ (current.state === "paused" && input.state === "active" && !chatTaskNeedsNewTime(toTask(current))) ||
756
1042
  (current.state === "needs_attention" && current.schedule_kind === "cron" && input.state === "active");
757
1043
  if (!allowed) return null;
758
1044
  const mandate = await loadTaskMandate(current, tx);
@@ -760,6 +1046,13 @@ export const aiChatTasks = {
760
1046
  throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
761
1047
  }
762
1048
  if (input.state === "active") {
1049
+ const [available] = await tx`SELECT sponsor.id FROM auth.users sponsor JOIN ai.conversations conversation
1050
+ ON conversation.created_by_user_id = sponsor.id WHERE sponsor.id = ${current.sponsor_user_id}::uuid
1051
+ AND conversation.id = ${current.conversation_id}::uuid AND conversation.archived_at IS NULL
1052
+ AND (sponsor.account_expires IS NULL OR sponsor.account_expires > now())
1053
+ FOR SHARE OF conversation`;
1054
+ // Held until this resume commits, so an archive waits for it and its follow-up then pauses the routine again.
1055
+ if (!available) throw new AiChatTaskAuthorityError("Scheduled task sponsor or chat is unavailable");
763
1056
  const policy = taskMandatePolicy(mandate);
764
1057
  if (!mandate.confirmed_at || !mandate.unexpired || !policy || !isChatTaskMandatePolicy(policy)) {
765
1058
  throw new AiChatTaskAuthorityError("Scheduled task mandate cannot be resumed");
@@ -768,7 +1061,9 @@ export const aiChatTasks = {
768
1061
  const revision = await mandateRevision({ mandate_id: mandate.id, mandate_revision: mandate.revision }, input.state, input.userId, tx);
769
1062
  const updated = await tx<TaskRow[]>`
770
1063
  UPDATE ai.chat_tasks task
771
- SET state = ${input.state}, mandate_revision = ${revision},
1064
+ SET state = ${input.state === "paused" ? chatTaskReasonState("paused") : "active"}, mandate_revision = ${revision},
1065
+ reason_code = ${input.state === "paused" ? "paused" : null},
1066
+ reason_at = CASE WHEN ${input.state} = 'active' THEN NULL WHEN task.reason_code = 'paused' THEN task.reason_at ELSE now() END,
772
1067
  last_error = CASE WHEN task.state = ${input.state} THEN task.last_error ELSE NULL END,
773
1068
  revision = CASE WHEN task.state = ${input.state} THEN task.revision ELSE task.revision + 1 END,
774
1069
  updated_at = CASE WHEN task.state = ${input.state} THEN task.updated_at ELSE now() END
@@ -776,12 +1071,13 @@ export const aiChatTasks = {
776
1071
  WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
777
1072
  RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
778
1073
  `;
1074
+ if (input.state === "paused") await stop(current, "paused");
779
1075
  return updated[0] ? toTask(updated[0]) : null;
780
1076
  });
781
1077
  },
782
1078
 
783
1079
  delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
784
- return sql.begin(async (tx) => {
1080
+ return withTaskAbortTransaction(async (tx, stop) => {
785
1081
  const rows = await tx<TaskRow[]>`
786
1082
  ${taskSelect()}
787
1083
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
@@ -789,6 +1085,7 @@ export const aiChatTasks = {
789
1085
  `;
790
1086
  let current = rows[0];
791
1087
  if (!current) return false;
1088
+ await stop(current, "deleted");
792
1089
  if (!current.mandate_id && current.mandate_revision === null) {
793
1090
  await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
794
1091
  return true;
@@ -851,9 +1148,9 @@ export const aiChatTasks = {
851
1148
  requestKey: string;
852
1149
  expectedRevision?: number;
853
1150
  }): Promise<AiChatTaskOccurrence | null> => {
854
- return sql.begin(async (tx) => {
1151
+ return beginWithAppLocale(async (tx, appLocale) => {
855
1152
  const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
856
- if (!current || !(await prepareTaskForExecution(current, tx))) return null;
1153
+ if (!current || !(await prepareTaskForExecution(current, tx, appLocale))) return null;
857
1154
  // One occurrence per schedule; timer ticks during a run do not accumulate.
858
1155
  const [inFlight] = await tx<
859
1156
  OccurrenceRow[]
@@ -914,6 +1211,66 @@ export const aiChatTasks = {
914
1211
  return occurrences;
915
1212
  },
916
1213
 
1214
+ /** Removes expired runs and their evidence, preserving current once slot rows as dedupe guards. */
1215
+ pruneExpiredRuns: async (input: { now?: Date; limit?: number } = {}): Promise<number> => {
1216
+ const cutoff = new Date((input.now ?? new Date()).getTime() - AI_CHAT_TASK_RUN_RETENTION_DAYS * 86_400_000);
1217
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
1218
+ return sql.begin(async (tx) => {
1219
+ // Keep the current once slot row as a dedupe guard while its turn and transcript expire.
1220
+ const occurrences = await tx<{ id: string; turn_id: string | null; keep: boolean }[]>`
1221
+ SELECT occurrence.id, occurrence.turn_id, EXISTS (
1222
+ SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
1223
+ AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
1224
+ ) AS keep
1225
+ FROM ai.chat_task_occurrences occurrence
1226
+ WHERE occurrence.state IN ('completed', 'failed', 'stopped') AND occurrence.completed_at < ${cutoff}
1227
+ AND (occurrence.turn_id IS NOT NULL OR NOT EXISTS (
1228
+ SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
1229
+ AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
1230
+ ))
1231
+ AND NOT EXISTS (
1232
+ SELECT 1 FROM ai.turns turn WHERE turn.id = occurrence.turn_id
1233
+ AND turn.status NOT IN ('completed', 'failed', 'aborted')
1234
+ )
1235
+ ORDER BY occurrence.completed_at, occurrence.id
1236
+ LIMIT ${limit}
1237
+ FOR UPDATE OF occurrence SKIP LOCKED
1238
+ `;
1239
+ if (occurrences.length > 0) {
1240
+ const occurrenceIds = toPgUuidArray(occurrences.filter((occurrence) => !occurrence.keep).map((occurrence) => occurrence.id));
1241
+ const turnIds = toPgUuidArray(occurrences.flatMap((occurrence) => (occurrence.turn_id ? [occurrence.turn_id] : [])));
1242
+ await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${turnIds}::text[])`;
1243
+ await tx`
1244
+ DELETE FROM ai.turns WHERE id = ANY(${turnIds}::uuid[])
1245
+ AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
1246
+ `;
1247
+ await tx`DELETE FROM ai.chat_task_occurrences WHERE id = ANY(${occurrenceIds}::uuid[])`;
1248
+ }
1249
+ const remaining = limit - occurrences.length;
1250
+ if (remaining === 0) return occurrences.length;
1251
+ const orphanedTurns = await tx<{ id: string }[]>`
1252
+ SELECT turn.id FROM ai.turns turn
1253
+ WHERE turn.run_config ? 'background' AND turn.status IN ('completed', 'failed', 'aborted')
1254
+ AND turn.completed_at < ${cutoff}
1255
+ AND NOT EXISTS (
1256
+ SELECT 1 FROM ai.chat_task_occurrences occurrence WHERE occurrence.turn_id = turn.id
1257
+ )
1258
+ ORDER BY turn.completed_at, turn.id
1259
+ LIMIT ${remaining}
1260
+ FOR UPDATE OF turn SKIP LOCKED
1261
+ `;
1262
+ if (orphanedTurns.length === 0) return occurrences.length;
1263
+ const orphanedTurnIds = toPgUuidArray(orphanedTurns.map((turn) => turn.id));
1264
+ await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${orphanedTurnIds}::text[])`;
1265
+ const removedTurns = await tx<{ id: string }[]>`
1266
+ DELETE FROM ai.turns WHERE id = ANY(${orphanedTurnIds}::uuid[])
1267
+ AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
1268
+ RETURNING id
1269
+ `;
1270
+ return occurrences.length + removedTurns.length;
1271
+ });
1272
+ },
1273
+
917
1274
  listQueuedOccurrences: async (limit = 100): Promise<Array<{ occurrence: AiChatTaskOccurrence; task: AiChatTask }>> => {
918
1275
  const rows = await sql<(OccurrenceRow & TaskRow)[]>`
919
1276
  SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
@@ -921,7 +1278,7 @@ export const aiChatTasks = {
921
1278
  task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
922
1279
  conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
923
1280
  (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
924
- task.prompt, task.schedule_kind, task.run_at, task.cron,
1281
+ task.title, task.locale, task.reason_code, task.reason_at, task.prompt, task.schedule_kind, task.run_at, task.cron,
925
1282
  task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
926
1283
  FROM (
927
1284
  SELECT candidate.*,
@@ -955,7 +1312,7 @@ export const aiChatTasks = {
955
1312
  },
956
1313
 
957
1314
  getQueuedOccurrence: async (occurrenceId: string): Promise<{ occurrence: AiChatTaskOccurrence; task: AiChatTask } | null> => {
958
- return sql.begin(async (tx) => {
1315
+ return beginWithAppLocale(async (tx, appLocale) => {
959
1316
  const occurrences = await tx<OccurrenceRow[]>`
960
1317
  SELECT * FROM ai.chat_task_occurrences WHERE id = ${occurrenceId}::uuid AND state = 'queued' LIMIT 1
961
1318
  `;
@@ -968,7 +1325,7 @@ export const aiChatTasks = {
968
1325
  FOR UPDATE OF task
969
1326
  `;
970
1327
  if (!tasks[0]) return null;
971
- const task = await prepareTaskForExecution(tasks[0], tx);
1328
+ const task = await prepareTaskForExecution(tasks[0], tx, appLocale);
972
1329
  if (!task) return null;
973
1330
  await tx`
974
1331
  UPDATE ai.chat_task_occurrences SET task_revision = ${task.revision}::bigint
@@ -978,8 +1335,8 @@ export const aiChatTasks = {
978
1335
  });
979
1336
  },
980
1337
 
981
- failOccurrence: async (input: { occurrenceId: string; error: string }): Promise<"failed" | "stale" | "gone"> =>
982
- sql.begin(async (tx) => {
1338
+ failOccurrence: async (input: { occurrenceId: string; error: string; code: ChatTaskReasonCode }): Promise<"failed" | "stale" | "gone"> =>
1339
+ beginWithAppLocale(async (tx, appLocale) => {
983
1340
  const task = await loadOccurrenceTask(input.occurrenceId, tx);
984
1341
  if (!task) return "gone" as const;
985
1342
  const rows = await tx<{ task_id: string; task_revision: number | bigint }[]>`
@@ -993,14 +1350,16 @@ export const aiChatTasks = {
993
1350
  RETURNING occurrence.task_id, occurrence.task_revision
994
1351
  `;
995
1352
  if (rows[0]) {
996
- await deliverOccurrenceResult(tx, task, input.occurrenceId, input.error);
997
1353
  const revision = await pauseTerminalTaskMandate(task, tx);
998
1354
  await tx`
999
1355
  UPDATE ai.chat_tasks
1000
- SET state = 'needs_attention', last_error = ${input.error}, mandate_revision = ${revision},
1356
+ SET state = ${chatTaskReasonState(input.code)}, reason_code = ${input.code}, reason_at = CASE WHEN reason_code = ${input.code} THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${input.error}, mandate_revision = ${revision},
1001
1357
  revision = revision + 1, updated_at = now()
1002
1358
  WHERE id = ${rows[0].task_id}::uuid AND revision = ${rows[0].task_revision}::bigint
1003
1359
  `;
1360
+ const [stopped] = await tx<TaskRow[]>`SELECT * FROM ai.chat_tasks WHERE id = ${task.id}::uuid`;
1361
+ const reason = { code: input.code, at: iso(stopped!.reason_at!) };
1362
+ await deliverOccurrenceResult(tx, task, input.occurrenceId, reasonSentence(task, reason, appLocale), { reason, reasonOnly: true });
1004
1363
  return "failed" as const;
1005
1364
  }
1006
1365
  const stale = await tx<{ stale: boolean }[]>`
@@ -1022,9 +1381,9 @@ export const aiChatTasks = {
1022
1381
  userMessage: Message;
1023
1382
  expectedRevision: number;
1024
1383
  }): Promise<{ delivered: true; conversationId: string; turnId: string } | { delivered: false; reason: "not_found" | "busy" | "stale" }> =>
1025
- sql.begin(async (tx) => {
1384
+ beginWithAppLocale(async (tx, appLocale) => {
1026
1385
  const current = await loadOccurrenceTask(input.occurrenceId, tx);
1027
- const task = current ? await prepareTaskForExecution(current, tx) : null;
1386
+ const task = current ? await prepareTaskForExecution(current, tx, appLocale) : null;
1028
1387
  if (!task) {
1029
1388
  return { delivered: false as const, reason: "not_found" as const };
1030
1389
  }
@@ -1071,9 +1430,13 @@ export const aiChatTasks = {
1071
1430
  `;
1072
1431
  const runConfig: AiChatTurnRunConfig = {
1073
1432
  ...input.runConfig,
1433
+ locale: chatTaskLocale(task.locale, appLocale),
1074
1434
  background: {
1075
1435
  taskId: task.short_id,
1076
1436
  occurrenceId: occurrence.short_id,
1437
+ scheduledFor: iso(occurrence.scheduled_for),
1438
+ trigger: occurrence.trigger,
1439
+ timezone: task.timezone,
1077
1440
  context: contextRows.map((row, index) => ({ seq: index + 1, kind: row.kind, message: row.message })),
1078
1441
  },
1079
1442
  mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
@@ -1123,8 +1486,25 @@ export const aiChatTasks = {
1123
1486
  status: AiTurnStatus;
1124
1487
  error?: string | null;
1125
1488
  }): Promise<{ occurrenceId: string; failed: boolean } | null> =>
1126
- sql.begin(async (tx) => {
1489
+ beginWithAppLocale(async (tx, appLocale) => {
1127
1490
  const task = await loadTurnTask(input.turnId, tx);
1491
+ // A plain read: the stop line claims the turn itself, after it locked the chat as archiving does.
1492
+ const [turn] = await tx<StoppedTurn[]>`SELECT id, conversation_id, cancellation_reason, run_config FROM ai.turns
1493
+ WHERE id = ${input.turnId}::uuid`;
1494
+ if (!turn) return null;
1495
+ if (turn.cancellation_reason === "routine_paused" || turn.cancellation_reason === "routine_deleted") {
1496
+ const text = await deliverStoppedTurn(
1497
+ tx,
1498
+ turn,
1499
+ turn.cancellation_reason === "routine_paused" ? "paused" : "deleted",
1500
+ task,
1501
+ appLocale,
1502
+ );
1503
+ const [occurrence] = await tx<{ id: string }[]>`UPDATE ai.chat_task_occurrences
1504
+ SET state = 'stopped', error = NULL, result_text = COALESCE(${text}, result_text), completed_at = now(), delivered_at = now()
1505
+ WHERE turn_id = ${input.turnId}::uuid AND state = 'running' RETURNING id`;
1506
+ return occurrence ? { occurrenceId: occurrence.id, failed: false } : null;
1507
+ }
1128
1508
  if (!task) return null;
1129
1509
  const rows = await tx<
1130
1510
  {
@@ -1137,6 +1517,7 @@ export const aiChatTasks = {
1137
1517
  task_revision: number | bigint;
1138
1518
  current_revision: number | bigint;
1139
1519
  turn_error: string | null;
1520
+ completed_at: Date | string;
1140
1521
  }[]
1141
1522
  >`
1142
1523
  UPDATE ai.chat_task_occurrences occurrence
@@ -1150,7 +1531,7 @@ export const aiChatTasks = {
1150
1531
  WHERE occurrence.task_id = task.id AND occurrence.turn_id = ${input.turnId}::uuid AND turn.id = occurrence.turn_id
1151
1532
  AND occurrence.state = 'running'
1152
1533
  RETURNING occurrence.id AS occurrence_id, occurrence.task_id, task.schedule_kind, occurrence.trigger,
1153
- occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error
1534
+ occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error, occurrence.completed_at
1154
1535
  `;
1155
1536
  const row = rows[0];
1156
1537
  if (!row) return null;
@@ -1168,13 +1549,7 @@ export const aiChatTasks = {
1168
1549
  .join("\n")
1169
1550
  .trim();
1170
1551
  })
1171
- .find(Boolean) ||
1172
- input.error ||
1173
- row.turn_error ||
1174
- (input.status === "completed"
1175
- ? "Background task completed."
1176
- : "Background task could not finish. Please review this run in the chat.");
1177
- await deliverOccurrenceResult(tx, task, row.occurrence_id, resultText);
1552
+ .find(Boolean) || (input.status === "completed" ? "Background task completed." : "");
1178
1553
  const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
1179
1554
  const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
1180
1555
  if (
@@ -1187,7 +1562,7 @@ export const aiChatTasks = {
1187
1562
  const revision = await pauseTerminalTaskMandate(task, tx);
1188
1563
  await tx`
1189
1564
  UPDATE ai.chat_tasks
1190
- SET state = 'completed', last_error = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1565
+ SET state = 'completed', last_error = NULL, reason_code = NULL, reason_at = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1191
1566
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1192
1567
  `;
1193
1568
  } else if (unchangedSinceStart && input.status !== "completed") {
@@ -1195,24 +1570,43 @@ export const aiChatTasks = {
1195
1570
  const revision = await pauseTerminalTaskMandate(task, tx);
1196
1571
  await tx`
1197
1572
  UPDATE ai.chat_tasks
1198
- SET state = 'needs_attention', last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1573
+ SET state = ${chatTaskReasonState("failed")}, reason_code = 'failed', reason_at = CASE WHEN reason_code = 'failed' THEN COALESCE(reason_at, now()) ELSE now() END, last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
1199
1574
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1200
1575
  `;
1201
1576
  }
1577
+ const [current] = await tx<TaskRow[]>`SELECT * FROM ai.chat_tasks WHERE id = ${task.id}::uuid`;
1578
+ const reason: ChatTaskReason | null =
1579
+ input.status !== "completed"
1580
+ ? { code: "failed", at: current?.reason_code === "failed" && current.reason_at ? iso(current.reason_at) : iso(row.completed_at) }
1581
+ : null;
1582
+ await deliverOccurrenceResult(
1583
+ tx,
1584
+ task,
1585
+ row.occurrence_id,
1586
+ resultText || (reason ? reasonSentence(task, reason, appLocale) : ""),
1587
+ reason ? { reason, ...(resultText ? {} : { reasonOnly: true as const }) } : {},
1588
+ );
1202
1589
  return { occurrenceId: row.occurrence_id, failed: input.status !== "completed" };
1203
1590
  }),
1204
1591
 
1205
1592
  listTerminalRunningTurns: async (limit = 100): Promise<Array<{ turnId: string; status: "completed" | "failed" | "aborted" }>> => {
1206
1593
  const rows = await sql<{ turn_id: string; status: "completed" | "failed" | "aborted" }[]>`
1207
- SELECT occurrence.turn_id, turn.status
1208
- FROM ai.chat_task_occurrences occurrence
1209
- JOIN ai.chat_tasks task ON task.id = occurrence.task_id
1210
- JOIN ai.conversations conversation ON conversation.id = task.conversation_id
1211
- JOIN ai.turns turn ON turn.id = occurrence.turn_id
1212
- WHERE occurrence.state = 'running'
1213
- AND turn.status IN ('completed', 'failed', 'aborted')
1214
- ORDER BY turn.completed_at, occurrence.id
1215
- LIMIT ${Math.min(Math.max(limit, 1), 500)}
1594
+ SELECT turn_id, status FROM (
1595
+ (SELECT occurrence.turn_id, turn.status, turn.completed_at
1596
+ FROM ai.chat_task_occurrences occurrence JOIN ai.turns turn ON turn.id = occurrence.turn_id
1597
+ WHERE occurrence.state = 'running' AND turn.status IN ('completed', 'failed', 'aborted')
1598
+ ORDER BY turn.completed_at, occurrence.turn_id LIMIT ${Math.min(Math.max(limit, 1), 500)})
1599
+ UNION
1600
+ (SELECT turn.id AS turn_id, turn.status, turn.completed_at FROM ai.turns turn
1601
+ -- A paused run whose routine was deleted before its line went out has no occurrence left either.
1602
+ WHERE turn.cancellation_reason IN ('routine_deleted', 'routine_paused') AND turn.run_config ? 'background'
1603
+ AND turn.status IN ('completed', 'failed', 'aborted')
1604
+ AND NOT EXISTS (SELECT 1 FROM ai.chat_task_occurrences occurrence WHERE occurrence.turn_id = turn.id)
1605
+ AND turn.completed_at >= now() - interval '1 day'
1606
+ AND turn.run_config #>> '{background,stopDeliveredAt}' IS NULL
1607
+ ORDER BY turn.completed_at, turn.id LIMIT ${Math.min(Math.max(limit, 1), 500)})
1608
+ ) pending
1609
+ ORDER BY completed_at, turn_id LIMIT ${Math.min(Math.max(limit, 1), 500)}
1216
1610
  `;
1217
1611
  return rows.map((row) => ({ turnId: row.turn_id, status: row.status }));
1218
1612
  },