@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.
- package/package.json +6 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
package/src/ai/chat-tasks.ts
CHANGED
|
@@ -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
|
-
|
|
249
|
+
code: ChatTaskReasonCode,
|
|
226
250
|
reason: string | null,
|
|
227
251
|
db: SQL,
|
|
228
|
-
|
|
252
|
+
appLocale: string | undefined,
|
|
253
|
+
pausedMandateRevision?: number | null,
|
|
229
254
|
): Promise<null> => {
|
|
230
|
-
|
|
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 =
|
|
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)
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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, "
|
|
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, "
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
},
|