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