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