@k2b/cloud 0.38.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 (99) hide show
  1. package/package.json +6 -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/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -17,8 +17,23 @@ import {
17
17
  validateMandateIssueAuthority,
18
18
  } from "../services/mandates";
19
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
 
24
39
  /** Run history and transcripts are operational evidence; 90 days matches capability execution history.
@@ -28,6 +43,11 @@ export const AI_CHAT_TASK_RUN_RETENTION_DAYS = 90;
28
43
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
29
44
  export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
30
45
  export type AiChatTask = {
46
+ title: string | null;
47
+ name: string;
48
+ locale: string | null;
49
+ reasonCode: ChatTaskReasonCode | null;
50
+ reasonAt: string | null;
31
51
  id: string;
32
52
  shortId: string;
33
53
  chatId: string;
@@ -47,7 +67,7 @@ export type AiChatTask = {
47
67
  updatedAt: string;
48
68
  };
49
69
 
50
- export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed";
70
+ export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed" | "stopped";
51
71
  export type AiChatTaskOccurrence = {
52
72
  id: string;
53
73
  shortId: string;
@@ -64,6 +84,10 @@ export type AiChatTaskOccurrence = {
64
84
  };
65
85
 
66
86
  type TaskRow = {
87
+ title: string | null;
88
+ locale: string | null;
89
+ reason_code: ChatTaskReasonCode | null;
90
+ reason_at: Date | string | null;
67
91
  id: string;
68
92
  short_id: string;
69
93
  conversation_id: string;
@@ -222,17 +246,19 @@ const taskMandatePolicy = (mandate: TaskMandateRow): MandatePolicyV1 | null => {
222
246
  };
223
247
  const stopTaskAdmission = async (
224
248
  task: TaskRow,
225
- state: "paused" | "needs_attention",
249
+ code: ChatTaskReasonCode,
226
250
  reason: string | null,
227
251
  db: SQL,
228
- pausedMandateRevision?: number,
252
+ appLocale: string | undefined,
253
+ pausedMandateRevision?: number | null,
229
254
  ): Promise<null> => {
230
- await db`
255
+ const state = chatTaskReasonState(code);
256
+ const [stopped] = await db<TaskRow[]>`
231
257
  UPDATE ai.chat_tasks
232
- SET state = ${state}, last_error = ${reason},
258
+ SET state = ${state}, last_error = ${reason}, reason_code = ${code}, reason_at = now(),
233
259
  mandate_revision = COALESCE(${pausedMandateRevision ?? null}::bigint, mandate_revision),
234
260
  revision = revision + 1, updated_at = now()
235
- WHERE id = ${task.id}::uuid AND state = 'active'
261
+ WHERE id = ${task.id}::uuid AND state = ${task.state} RETURNING *
236
262
  `;
237
263
  if (state === "needs_attention" && reason) {
238
264
  const occurrences = await db<{ id: string }[]>`
@@ -252,26 +278,45 @@ const stopTaskAdmission = async (
252
278
  );
253
279
  occurrences.push(...failed);
254
280
  }
255
- 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
+ }
256
288
  }
257
289
  return null;
258
290
  };
259
291
  /** 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> => {
292
+ const prepareTaskForExecution = async (input: TaskRow, db: SQL, appLocale: string | undefined): Promise<TaskRow | null> => {
261
293
  if (input.state !== "active") return null;
262
294
  const task = input;
263
295
  const mandate = await loadTaskMandate(task, db);
264
296
  const policy = mandate ? taskMandatePolicy(mandate) : null;
265
297
  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
- `;
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.
273
318
  if (
274
- !sponsor ||
319
+ conversation.created_by_user_id !== task.sponsor_user_id ||
275
320
  !mandate ||
276
321
  !taskMandateMatches(task, mandate) ||
277
322
  !mandate.confirmed_at ||
@@ -280,12 +325,13 @@ const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow
280
325
  !isChatTaskMandatePolicy(policy) ||
281
326
  mandate.state === "revoked"
282
327
  ) {
283
- 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);
284
329
  }
285
- 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));
286
331
  if (task.mandate_revision === null || Number(task.mandate_revision) !== Number(mandate.revision)) {
287
- return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate revision changed", db);
332
+ return stopTaskAdmission(task, "rights_changed", "Scheduled task mandate revision changed", db, appLocale);
288
333
  }
334
+ if (conversation.archived_at) return archived();
289
335
  return task;
290
336
  };
291
337
  /** Terminal accounting may narrow existing authority, but must never create or resume it. */
@@ -303,12 +349,23 @@ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number
303
349
  const taskFingerprint = (input: {
304
350
  chatId: string;
305
351
  prompt: string;
352
+ title?: string;
306
353
  schedule: AiChatTaskSchedule;
307
354
  timezone: string;
308
355
  grants?: TaskGrant[];
309
356
  }): string =>
310
357
  new Bun.CryptoHasher("sha256")
311
- .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
+ )
312
369
  .digest("hex");
313
370
  const taskSelect = () => sql`
314
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
@@ -325,6 +382,11 @@ const taskGrants = (row: TaskRow): TaskGrant[] => {
325
382
  }
326
383
  };
327
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),
328
390
  id: row.id,
329
391
  shortId: row.short_id,
330
392
  chatId: row.conversation_short_id,
@@ -360,13 +422,13 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
360
422
  });
361
423
 
362
424
  const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
363
- sql.begin(async (tx) => {
425
+ beginWithAppLocale(async (tx, appLocale) => {
364
426
  const [task] = await tx<TaskRow[]>`
365
427
  ${taskSelect()}
366
428
  WHERE task.id = ${taskId}::uuid
367
429
  FOR UPDATE OF task
368
430
  `;
369
- const prepared = task ? await prepareTaskForExecution(task, tx) : null;
431
+ const prepared = task ? await prepareTaskForExecution(task, tx, appLocale) : null;
370
432
  return prepared ? toTask(prepared) : null;
371
433
  });
372
434
  const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
@@ -392,8 +454,26 @@ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> =>
392
454
  return task ?? null;
393
455
  };
394
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
+
395
466
  /** 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> => {
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> => {
397
477
  const [occurrence] = await db<OccurrenceRow[]>`
398
478
  UPDATE ai.chat_task_occurrences SET result_text = ${resultText}, delivered_at = now()
399
479
  WHERE id = ${occurrenceId}::uuid AND delivered_at IS NULL RETURNING *
@@ -410,6 +490,7 @@ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: str
410
490
  occurrenceId: occurrence.short_id,
411
491
  scheduledFor: iso(occurrence.scheduled_for),
412
492
  trigger: occurrence.trigger,
493
+ ...extra,
413
494
  },
414
495
  };
415
496
  await withAiShortIdForDb(
@@ -425,6 +506,98 @@ const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: str
425
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`;
426
507
  };
427
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
+
428
601
  export const aiChatTasks = {
429
602
  listActivities: async (input: { userId: string; limit?: number; offset?: number }) => {
430
603
  const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
@@ -552,6 +725,8 @@ export const aiChatTasks = {
552
725
  userId: string;
553
726
  chatId: string;
554
727
  prompt: string;
728
+ title?: string;
729
+ locale?: string;
555
730
  schedule: AiChatTaskSchedule;
556
731
  timezone: string;
557
732
  grants?: TaskGrant[];
@@ -603,11 +778,11 @@ export const aiChatTasks = {
603
778
  "ai_chat_tasks_short_id_unique",
604
779
  (attempt, shortId) => attempt<TaskRow[]>`
605
780
  INSERT INTO ai.chat_tasks (
606
- 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,
607
782
  mandate_id, mandate_revision, idempotency_key, idempotency_fingerprint
608
783
  )
609
784
  VALUES (
610
- ${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},
611
786
  ${input.schedule.kind === "once" ? input.schedule.runAt : null}::timestamptz,
612
787
  ${input.schedule.kind === "cron" ? input.schedule.cron : null}, ${input.timezone},
613
788
  ${mandate.data.id}::uuid, ${mandate.data.revision}, ${scopedKey}, ${fingerprint}
@@ -658,11 +833,12 @@ export const aiChatTasks = {
658
833
  userId: string;
659
834
  taskId: string;
660
835
  prompt?: string;
836
+ title?: string;
661
837
  grants?: TaskGrant[];
662
838
  schedule?: AiChatTaskSchedule;
663
839
  timezone?: string;
664
840
  }): Promise<AiChatTask | null> =>
665
- sql.begin(async (tx) => {
841
+ beginWithAppLocale(async (tx, appLocale) => {
666
842
  const rows = await tx<TaskRow[]>`
667
843
  ${taskSelect()}
668
844
  WHERE task.sponsor_user_id = ${input.userId}::uuid
@@ -673,7 +849,7 @@ export const aiChatTasks = {
673
849
  if (!current) return null;
674
850
  requireTaskMandate(current);
675
851
  const wasActive = current.state === "active";
676
- const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
852
+ const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx, appLocale));
677
853
  if (authorityStopped) {
678
854
  [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
679
855
  if (!current) return null;
@@ -702,17 +878,27 @@ export const aiChatTasks = {
702
878
  if (!updated.ok) throw mandateError("update", updated);
703
879
  updatedMandateRevision = updated.data.revision;
704
880
  }
705
- 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
+ }
706
892
  await tx`
707
893
  UPDATE ai.chat_tasks task
708
- 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},
709
895
  schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
710
896
  run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
711
897
  cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
712
898
  timezone = ${input.timezone ?? current.timezone},
713
899
  state = CASE
714
900
  WHEN ${authorityStopped} THEN task.state
715
- WHEN task.state = 'paused' THEN 'paused'
901
+ WHEN task.state = 'paused' THEN ${chatTaskReasonState("paused")}
716
902
  WHEN task.state = 'completed' AND ${scheduleChanged} = false THEN 'completed'
717
903
  ELSE 'active'
718
904
  END,
@@ -721,6 +907,8 @@ export const aiChatTasks = {
721
907
  THEN task.last_error
722
908
  ELSE NULL
723
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,
724
912
  revision = task.revision + 1, updated_at = now()
725
913
  WHERE id = ${current.id}::uuid
726
914
  `;
@@ -743,8 +931,95 @@ export const aiChatTasks = {
743
931
  return toTask(next);
744
932
  }),
745
933
 
746
- 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;
747
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) => {
748
1023
  const rows = await tx<TaskRow[]>`
749
1024
  ${taskSelect()}
750
1025
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
@@ -752,11 +1027,18 @@ export const aiChatTasks = {
752
1027
  `;
753
1028
  let current = rows[0];
754
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
+ }
755
1037
  requireTaskMandate(current);
756
1038
  const allowed =
757
1039
  current.state === input.state ||
758
1040
  (current.state === "active" && input.state === "paused") ||
759
- (current.state === "paused" && input.state === "active") ||
1041
+ (current.state === "paused" && input.state === "active" && !chatTaskNeedsNewTime(toTask(current))) ||
760
1042
  (current.state === "needs_attention" && current.schedule_kind === "cron" && input.state === "active");
761
1043
  if (!allowed) return null;
762
1044
  const mandate = await loadTaskMandate(current, tx);
@@ -764,6 +1046,13 @@ export const aiChatTasks = {
764
1046
  throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
765
1047
  }
766
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");
767
1056
  const policy = taskMandatePolicy(mandate);
768
1057
  if (!mandate.confirmed_at || !mandate.unexpired || !policy || !isChatTaskMandatePolicy(policy)) {
769
1058
  throw new AiChatTaskAuthorityError("Scheduled task mandate cannot be resumed");
@@ -772,7 +1061,9 @@ export const aiChatTasks = {
772
1061
  const revision = await mandateRevision({ mandate_id: mandate.id, mandate_revision: mandate.revision }, input.state, input.userId, tx);
773
1062
  const updated = await tx<TaskRow[]>`
774
1063
  UPDATE ai.chat_tasks task
775
- 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,
776
1067
  last_error = CASE WHEN task.state = ${input.state} THEN task.last_error ELSE NULL END,
777
1068
  revision = CASE WHEN task.state = ${input.state} THEN task.revision ELSE task.revision + 1 END,
778
1069
  updated_at = CASE WHEN task.state = ${input.state} THEN task.updated_at ELSE now() END
@@ -780,12 +1071,13 @@ export const aiChatTasks = {
780
1071
  WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
781
1072
  RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
782
1073
  `;
1074
+ if (input.state === "paused") await stop(current, "paused");
783
1075
  return updated[0] ? toTask(updated[0]) : null;
784
1076
  });
785
1077
  },
786
1078
 
787
1079
  delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
788
- return sql.begin(async (tx) => {
1080
+ return withTaskAbortTransaction(async (tx, stop) => {
789
1081
  const rows = await tx<TaskRow[]>`
790
1082
  ${taskSelect()}
791
1083
  WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
@@ -793,6 +1085,7 @@ export const aiChatTasks = {
793
1085
  `;
794
1086
  let current = rows[0];
795
1087
  if (!current) return false;
1088
+ await stop(current, "deleted");
796
1089
  if (!current.mandate_id && current.mandate_revision === null) {
797
1090
  await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
798
1091
  return true;
@@ -855,9 +1148,9 @@ export const aiChatTasks = {
855
1148
  requestKey: string;
856
1149
  expectedRevision?: number;
857
1150
  }): Promise<AiChatTaskOccurrence | null> => {
858
- return sql.begin(async (tx) => {
1151
+ return beginWithAppLocale(async (tx, appLocale) => {
859
1152
  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;
1153
+ if (!current || !(await prepareTaskForExecution(current, tx, appLocale))) return null;
861
1154
  // One occurrence per schedule; timer ticks during a run do not accumulate.
862
1155
  const [inFlight] = await tx<
863
1156
  OccurrenceRow[]
@@ -930,7 +1223,7 @@ export const aiChatTasks = {
930
1223
  AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
931
1224
  ) AS keep
932
1225
  FROM ai.chat_task_occurrences occurrence
933
- WHERE occurrence.state IN ('completed', 'failed') AND occurrence.completed_at < ${cutoff}
1226
+ WHERE occurrence.state IN ('completed', 'failed', 'stopped') AND occurrence.completed_at < ${cutoff}
934
1227
  AND (occurrence.turn_id IS NOT NULL OR NOT EXISTS (
935
1228
  SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
936
1229
  AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
@@ -985,7 +1278,7 @@ export const aiChatTasks = {
985
1278
  task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
986
1279
  conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
987
1280
  (SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
988
- 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,
989
1282
  task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
990
1283
  FROM (
991
1284
  SELECT candidate.*,
@@ -1019,7 +1312,7 @@ export const aiChatTasks = {
1019
1312
  },
1020
1313
 
1021
1314
  getQueuedOccurrence: async (occurrenceId: string): Promise<{ occurrence: AiChatTaskOccurrence; task: AiChatTask } | null> => {
1022
- return sql.begin(async (tx) => {
1315
+ return beginWithAppLocale(async (tx, appLocale) => {
1023
1316
  const occurrences = await tx<OccurrenceRow[]>`
1024
1317
  SELECT * FROM ai.chat_task_occurrences WHERE id = ${occurrenceId}::uuid AND state = 'queued' LIMIT 1
1025
1318
  `;
@@ -1032,7 +1325,7 @@ export const aiChatTasks = {
1032
1325
  FOR UPDATE OF task
1033
1326
  `;
1034
1327
  if (!tasks[0]) return null;
1035
- const task = await prepareTaskForExecution(tasks[0], tx);
1328
+ const task = await prepareTaskForExecution(tasks[0], tx, appLocale);
1036
1329
  if (!task) return null;
1037
1330
  await tx`
1038
1331
  UPDATE ai.chat_task_occurrences SET task_revision = ${task.revision}::bigint
@@ -1042,8 +1335,8 @@ export const aiChatTasks = {
1042
1335
  });
1043
1336
  },
1044
1337
 
1045
- failOccurrence: async (input: { occurrenceId: string; error: string }): Promise<"failed" | "stale" | "gone"> =>
1046
- sql.begin(async (tx) => {
1338
+ failOccurrence: async (input: { occurrenceId: string; error: string; code: ChatTaskReasonCode }): Promise<"failed" | "stale" | "gone"> =>
1339
+ beginWithAppLocale(async (tx, appLocale) => {
1047
1340
  const task = await loadOccurrenceTask(input.occurrenceId, tx);
1048
1341
  if (!task) return "gone" as const;
1049
1342
  const rows = await tx<{ task_id: string; task_revision: number | bigint }[]>`
@@ -1057,14 +1350,16 @@ export const aiChatTasks = {
1057
1350
  RETURNING occurrence.task_id, occurrence.task_revision
1058
1351
  `;
1059
1352
  if (rows[0]) {
1060
- await deliverOccurrenceResult(tx, task, input.occurrenceId, input.error);
1061
1353
  const revision = await pauseTerminalTaskMandate(task, tx);
1062
1354
  await tx`
1063
1355
  UPDATE ai.chat_tasks
1064
- 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},
1065
1357
  revision = revision + 1, updated_at = now()
1066
1358
  WHERE id = ${rows[0].task_id}::uuid AND revision = ${rows[0].task_revision}::bigint
1067
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 });
1068
1363
  return "failed" as const;
1069
1364
  }
1070
1365
  const stale = await tx<{ stale: boolean }[]>`
@@ -1086,9 +1381,9 @@ export const aiChatTasks = {
1086
1381
  userMessage: Message;
1087
1382
  expectedRevision: number;
1088
1383
  }): Promise<{ delivered: true; conversationId: string; turnId: string } | { delivered: false; reason: "not_found" | "busy" | "stale" }> =>
1089
- sql.begin(async (tx) => {
1384
+ beginWithAppLocale(async (tx, appLocale) => {
1090
1385
  const current = await loadOccurrenceTask(input.occurrenceId, tx);
1091
- const task = current ? await prepareTaskForExecution(current, tx) : null;
1386
+ const task = current ? await prepareTaskForExecution(current, tx, appLocale) : null;
1092
1387
  if (!task) {
1093
1388
  return { delivered: false as const, reason: "not_found" as const };
1094
1389
  }
@@ -1135,9 +1430,13 @@ export const aiChatTasks = {
1135
1430
  `;
1136
1431
  const runConfig: AiChatTurnRunConfig = {
1137
1432
  ...input.runConfig,
1433
+ locale: chatTaskLocale(task.locale, appLocale),
1138
1434
  background: {
1139
1435
  taskId: task.short_id,
1140
1436
  occurrenceId: occurrence.short_id,
1437
+ scheduledFor: iso(occurrence.scheduled_for),
1438
+ trigger: occurrence.trigger,
1439
+ timezone: task.timezone,
1141
1440
  context: contextRows.map((row, index) => ({ seq: index + 1, kind: row.kind, message: row.message })),
1142
1441
  },
1143
1442
  mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
@@ -1187,8 +1486,25 @@ export const aiChatTasks = {
1187
1486
  status: AiTurnStatus;
1188
1487
  error?: string | null;
1189
1488
  }): Promise<{ occurrenceId: string; failed: boolean } | null> =>
1190
- sql.begin(async (tx) => {
1489
+ beginWithAppLocale(async (tx, appLocale) => {
1191
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
+ }
1192
1508
  if (!task) return null;
1193
1509
  const rows = await tx<
1194
1510
  {
@@ -1201,6 +1517,7 @@ export const aiChatTasks = {
1201
1517
  task_revision: number | bigint;
1202
1518
  current_revision: number | bigint;
1203
1519
  turn_error: string | null;
1520
+ completed_at: Date | string;
1204
1521
  }[]
1205
1522
  >`
1206
1523
  UPDATE ai.chat_task_occurrences occurrence
@@ -1214,7 +1531,7 @@ export const aiChatTasks = {
1214
1531
  WHERE occurrence.task_id = task.id AND occurrence.turn_id = ${input.turnId}::uuid AND turn.id = occurrence.turn_id
1215
1532
  AND occurrence.state = 'running'
1216
1533
  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
1534
+ occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error, occurrence.completed_at
1218
1535
  `;
1219
1536
  const row = rows[0];
1220
1537
  if (!row) return null;
@@ -1232,13 +1549,7 @@ export const aiChatTasks = {
1232
1549
  .join("\n")
1233
1550
  .trim();
1234
1551
  })
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);
1552
+ .find(Boolean) || (input.status === "completed" ? "Background task completed." : "");
1242
1553
  const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
1243
1554
  const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
1244
1555
  if (
@@ -1251,7 +1562,7 @@ export const aiChatTasks = {
1251
1562
  const revision = await pauseTerminalTaskMandate(task, tx);
1252
1563
  await tx`
1253
1564
  UPDATE ai.chat_tasks
1254
- 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()
1255
1566
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1256
1567
  `;
1257
1568
  } else if (unchangedSinceStart && input.status !== "completed") {
@@ -1259,24 +1570,43 @@ export const aiChatTasks = {
1259
1570
  const revision = await pauseTerminalTaskMandate(task, tx);
1260
1571
  await tx`
1261
1572
  UPDATE ai.chat_tasks
1262
- 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()
1263
1574
  WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
1264
1575
  `;
1265
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
+ );
1266
1589
  return { occurrenceId: row.occurrence_id, failed: input.status !== "completed" };
1267
1590
  }),
1268
1591
 
1269
1592
  listTerminalRunningTurns: async (limit = 100): Promise<Array<{ turnId: string; status: "completed" | "failed" | "aborted" }>> => {
1270
1593
  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)}
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)}
1280
1610
  `;
1281
1611
  return rows.map((row) => ({ turnId: row.turn_id, status: row.status }));
1282
1612
  },