@k2b/cloud 0.6.0-rc.0 → 0.7.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 +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/chat-tasks.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { aiConversations } from "./store";
|
|
1
2
|
import type { Message } from "@k2b/nessi";
|
|
2
3
|
import { type SQL, sql } from "bun";
|
|
3
4
|
import { logger } from "../services/logging";
|
|
4
5
|
import {
|
|
5
6
|
createMandate,
|
|
7
|
+
CapabilityGrantsSchema,
|
|
8
|
+
type CapabilityGrant,
|
|
9
|
+
updateMandatePolicy,
|
|
10
|
+
validateMandateIssueAuthority,
|
|
6
11
|
type Mandate,
|
|
7
12
|
type MandatePolicyV1,
|
|
8
13
|
MandatePolicyV1Schema,
|
|
@@ -10,6 +15,7 @@ import {
|
|
|
10
15
|
resumeMandate,
|
|
11
16
|
revokeMandate,
|
|
12
17
|
} from "../services/mandates";
|
|
18
|
+
import { getCapability } from "../_internal/registry";
|
|
13
19
|
import { parsePgJsonValue } from "../services/postgres";
|
|
14
20
|
import { withAiShortIdForDb } from "./short-id";
|
|
15
21
|
import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
|
|
@@ -25,6 +31,7 @@ export type AiChatTask = {
|
|
|
25
31
|
sponsorUserId: string;
|
|
26
32
|
mandateId: string | null;
|
|
27
33
|
mandateRevision: number | null;
|
|
34
|
+
grants: CapabilityGrant[];
|
|
28
35
|
prompt: string;
|
|
29
36
|
schedule: AiChatTaskSchedule;
|
|
30
37
|
timezone: string;
|
|
@@ -45,6 +52,7 @@ export type AiChatTaskOccurrence = {
|
|
|
45
52
|
state: AiChatTaskOccurrenceState;
|
|
46
53
|
turnId: string | null;
|
|
47
54
|
error: string | null;
|
|
55
|
+
resultText: string | null;
|
|
48
56
|
createdAt: string;
|
|
49
57
|
startedAt: string | null;
|
|
50
58
|
completedAt: string | null;
|
|
@@ -59,6 +67,7 @@ type TaskRow = {
|
|
|
59
67
|
sponsor_user_id: string;
|
|
60
68
|
mandate_id: string | null;
|
|
61
69
|
mandate_revision: number | bigint | null;
|
|
70
|
+
mandate_policy?: unknown;
|
|
62
71
|
prompt: string;
|
|
63
72
|
schedule_kind: "once" | "cron";
|
|
64
73
|
run_at: Date | string | null;
|
|
@@ -91,6 +100,8 @@ type OccurrenceRow = {
|
|
|
91
100
|
state: AiChatTaskOccurrenceState;
|
|
92
101
|
turn_id: string | null;
|
|
93
102
|
error: string | null;
|
|
103
|
+
result_text?: string | null;
|
|
104
|
+
request_key?: string;
|
|
94
105
|
created_at: Date | string;
|
|
95
106
|
started_at: Date | string | null;
|
|
96
107
|
completed_at: Date | string | null;
|
|
@@ -99,21 +110,42 @@ type OccurrenceRow = {
|
|
|
99
110
|
const iso = (value: Date | string): string => new Date(value).toISOString();
|
|
100
111
|
const nullableIso = (value: Date | string | null): string | null => (value === null ? null : iso(value));
|
|
101
112
|
const taskIdempotencyKey = (key: string): string => `task.create:${key}`;
|
|
102
|
-
const
|
|
113
|
+
const chatTaskMandatePolicy = (grants: CapabilityGrant[]): MandatePolicyV1 => ({
|
|
103
114
|
version: 1,
|
|
104
|
-
apps:
|
|
105
|
-
operations:
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
apps: [...new Set(grants.map((grant) => grant.appId))],
|
|
116
|
+
operations: [
|
|
117
|
+
...new Set(
|
|
118
|
+
grants.flatMap((grant) =>
|
|
119
|
+
grant.kind === "query"
|
|
120
|
+
? [`capability.query:${grant.capabilityId}`]
|
|
121
|
+
: [`capability.action.review:${grant.capabilityId}`, `capability.action.run:${grant.capabilityId}`],
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
],
|
|
125
|
+
actions: grants.length ? "preapproved" : "deny",
|
|
126
|
+
grants,
|
|
127
|
+
});
|
|
128
|
+
const validateTaskGrants = async (input: unknown): Promise<CapabilityGrant[]> => {
|
|
129
|
+
const grants = CapabilityGrantsSchema.parse(input);
|
|
130
|
+
for (const grant of grants) {
|
|
131
|
+
const entry = await getCapability(grant.appId);
|
|
132
|
+
const operation = (grant.kind === "query" ? entry?.manifest.queries : entry?.manifest.actions)?.find(
|
|
133
|
+
(item) => item.localId === grant.capabilityId,
|
|
134
|
+
);
|
|
135
|
+
if (!operation) throw new Error(`Capability unavailable: ${grant.appId}.${grant.capabilityId}`);
|
|
136
|
+
if (grant.kind === "action" && (!("approval" in operation) || (operation.approval !== "none" && operation.approval !== "rememberable")))
|
|
137
|
+
throw new Error(`Capability always requires interactive approval: ${grant.appId}.${grant.capabilityId}`);
|
|
138
|
+
const properties = operation.inputSchema.properties ?? {};
|
|
139
|
+
for (const key of Object.keys(grant.fixedInput))
|
|
140
|
+
if (!Object.hasOwn(properties, key)) throw new Error(`Unknown fixed input ${key} for ${grant.appId}.${grant.capabilityId}`);
|
|
141
|
+
}
|
|
142
|
+
return grants;
|
|
143
|
+
};
|
|
108
144
|
const mandateAuthority = (userId: string) => ({ kind: "interactive" as const, userId });
|
|
109
145
|
const mandateWorkloadAuthority = { kind: "workload" as const, ownerAppId: "core" };
|
|
110
146
|
const mandateError = (operation: string, result: { ok: false; error: { message: string } }): Error =>
|
|
111
147
|
new AiChatTaskAuthorityError(`Could not ${operation} scheduled task mandate: ${result.error.message}`);
|
|
112
|
-
const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean =>
|
|
113
|
-
policy.version === CHAT_TASK_MANDATE_POLICY.version &&
|
|
114
|
-
policy.apps === CHAT_TASK_MANDATE_POLICY.apps &&
|
|
115
|
-
policy.operations === CHAT_TASK_MANDATE_POLICY.operations &&
|
|
116
|
-
policy.actions === CHAT_TASK_MANDATE_POLICY.actions;
|
|
148
|
+
const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean => policy.grants !== undefined;
|
|
117
149
|
const mandateRevision = async (
|
|
118
150
|
task: { mandate_id: string; mandate_revision: number | bigint },
|
|
119
151
|
state: "active" | "paused",
|
|
@@ -187,6 +219,26 @@ const stopTaskAdmission = async (
|
|
|
187
219
|
revision = revision + 1, updated_at = now()
|
|
188
220
|
WHERE id = ${task.id}::uuid AND state = 'active'
|
|
189
221
|
`;
|
|
222
|
+
if (state === "needs_attention" && reason) {
|
|
223
|
+
const occurrences = await db<{ id: string }[]>`
|
|
224
|
+
UPDATE ai.chat_task_occurrences SET state = 'failed', error = ${reason}, completed_at = now()
|
|
225
|
+
WHERE task_id = ${task.id}::uuid AND state = 'queued' RETURNING id
|
|
226
|
+
`;
|
|
227
|
+
if (!occurrences.length) {
|
|
228
|
+
// Reconciliation can detect revoked authority before the next occurrence exists.
|
|
229
|
+
const failed = await withAiShortIdForDb(
|
|
230
|
+
db,
|
|
231
|
+
"ai_chat_task_occurrences_short_id_unique",
|
|
232
|
+
(attempt, shortId) => attempt<{ id: string }[]>`
|
|
233
|
+
INSERT INTO ai.chat_task_occurrences(short_id, task_id, scheduled_for, trigger, request_key, task_revision, state, error, completed_at)
|
|
234
|
+
VALUES (${shortId}, ${task.id}::uuid, now(), 'scheduled', ${`authority:${task.id}:${task.revision}`}, ${task.revision}, 'failed', ${reason}, now())
|
|
235
|
+
ON CONFLICT DO NOTHING RETURNING id
|
|
236
|
+
`,
|
|
237
|
+
);
|
|
238
|
+
occurrences.push(...failed);
|
|
239
|
+
}
|
|
240
|
+
for (const occurrence of occurrences) await deliverOccurrenceResult(db, task, occurrence.id, reason);
|
|
241
|
+
}
|
|
190
242
|
return null;
|
|
191
243
|
};
|
|
192
244
|
/** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
|
|
@@ -233,14 +285,30 @@ const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number
|
|
|
233
285
|
if (!paused.ok) throw mandateError("pause", paused);
|
|
234
286
|
return paused.data.revision;
|
|
235
287
|
};
|
|
236
|
-
const taskFingerprint = (input: {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
288
|
+
const taskFingerprint = (input: {
|
|
289
|
+
chatId: string;
|
|
290
|
+
prompt: string;
|
|
291
|
+
schedule: AiChatTaskSchedule;
|
|
292
|
+
timezone: string;
|
|
293
|
+
grants?: CapabilityGrant[];
|
|
294
|
+
}): string =>
|
|
295
|
+
new Bun.CryptoHasher("sha256")
|
|
296
|
+
.update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone, input.grants ?? []]))
|
|
297
|
+
.digest("hex");
|
|
298
|
+
const taskSelect = () => sql`
|
|
299
|
+
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
|
|
240
300
|
FROM ai.chat_tasks task
|
|
241
301
|
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
242
302
|
`;
|
|
243
303
|
|
|
304
|
+
const taskGrants = (row: TaskRow): CapabilityGrant[] => {
|
|
305
|
+
try {
|
|
306
|
+
const policy = MandatePolicyV1Schema.safeParse(parsePgJsonValue(row.mandate_policy));
|
|
307
|
+
return policy.success ? (policy.data.grants ?? []) : [];
|
|
308
|
+
} catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
};
|
|
244
312
|
const toTask = (row: TaskRow): AiChatTask => ({
|
|
245
313
|
id: row.id,
|
|
246
314
|
shortId: row.short_id,
|
|
@@ -250,6 +318,7 @@ const toTask = (row: TaskRow): AiChatTask => ({
|
|
|
250
318
|
sponsorUserId: row.sponsor_user_id,
|
|
251
319
|
mandateId: row.mandate_id,
|
|
252
320
|
mandateRevision: row.mandate_revision === null ? null : Number(row.mandate_revision),
|
|
321
|
+
grants: taskGrants(row),
|
|
253
322
|
prompt: row.prompt,
|
|
254
323
|
schedule: row.schedule_kind === "once" ? { kind: "once", runAt: iso(row.run_at!) } : { kind: "cron", cron: row.cron! },
|
|
255
324
|
timezone: row.timezone,
|
|
@@ -269,6 +338,7 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
|
|
|
269
338
|
state: row.state,
|
|
270
339
|
turnId: row.turn_id,
|
|
271
340
|
error: row.error,
|
|
341
|
+
resultText: row.result_text ?? null,
|
|
272
342
|
createdAt: iso(row.created_at),
|
|
273
343
|
startedAt: nullableIso(row.started_at),
|
|
274
344
|
completedAt: nullableIso(row.completed_at),
|
|
@@ -277,7 +347,7 @@ const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
|
|
|
277
347
|
const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
|
|
278
348
|
sql.begin(async (tx) => {
|
|
279
349
|
const [task] = await tx<TaskRow[]>`
|
|
280
|
-
${taskSelect}
|
|
350
|
+
${taskSelect()}
|
|
281
351
|
WHERE task.id = ${taskId}::uuid
|
|
282
352
|
FOR UPDATE OF task
|
|
283
353
|
`;
|
|
@@ -286,7 +356,7 @@ const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
|
|
|
286
356
|
});
|
|
287
357
|
const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
|
|
288
358
|
const [task] = await db<TaskRow[]>`
|
|
289
|
-
${taskSelect}
|
|
359
|
+
${taskSelect()}
|
|
290
360
|
WHERE EXISTS (
|
|
291
361
|
SELECT 1 FROM ai.chat_task_occurrences occurrence
|
|
292
362
|
WHERE occurrence.id = ${occurrenceId}::uuid AND occurrence.task_id = task.id
|
|
@@ -297,7 +367,7 @@ const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRo
|
|
|
297
367
|
};
|
|
298
368
|
const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> => {
|
|
299
369
|
const [task] = await db<TaskRow[]>`
|
|
300
|
-
${taskSelect}
|
|
370
|
+
${taskSelect()}
|
|
301
371
|
WHERE EXISTS (
|
|
302
372
|
SELECT 1 FROM ai.chat_task_occurrences occurrence
|
|
303
373
|
WHERE occurrence.turn_id = ${turnId}::uuid AND occurrence.task_id = task.id
|
|
@@ -307,7 +377,110 @@ const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> =>
|
|
|
307
377
|
return task ?? null;
|
|
308
378
|
};
|
|
309
379
|
|
|
380
|
+
/** Called in the occurrence terminal transition transaction, so delivery is exactly once. */
|
|
381
|
+
const deliverOccurrenceResult = async (db: SQL, task: TaskRow, occurrenceId: string, resultText: string): Promise<void> => {
|
|
382
|
+
const [occurrence] = await db<OccurrenceRow[]>`
|
|
383
|
+
UPDATE ai.chat_task_occurrences SET result_text = ${resultText}, delivered_at = now()
|
|
384
|
+
WHERE id = ${occurrenceId}::uuid AND delivered_at IS NULL RETURNING *
|
|
385
|
+
`;
|
|
386
|
+
if (!occurrence) return;
|
|
387
|
+
const [destination] = await db<{ id: string }[]>`
|
|
388
|
+
SELECT id FROM ai.conversations WHERE id = ${task.conversation_id}::uuid AND archived_at IS NULL FOR UPDATE
|
|
389
|
+
`;
|
|
390
|
+
if (!destination) return;
|
|
391
|
+
const message: Message = { role: "assistant", content: [{ type: "text", text: resultText }] };
|
|
392
|
+
const meta = {
|
|
393
|
+
scheduledTask: {
|
|
394
|
+
taskId: task.short_id,
|
|
395
|
+
occurrenceId: occurrence.short_id,
|
|
396
|
+
scheduledFor: iso(occurrence.scheduled_for),
|
|
397
|
+
trigger: occurrence.trigger,
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
await withAiShortIdForDb(
|
|
401
|
+
db,
|
|
402
|
+
"idx_ai_messages_conversation_short_id",
|
|
403
|
+
(attempt, shortId) => attempt`
|
|
404
|
+
INSERT INTO ai.messages(short_id, conversation_id, seq, kind, role, message, search_text, meta)
|
|
405
|
+
VALUES (${shortId}, ${task.conversation_id}::uuid,
|
|
406
|
+
(SELECT COALESCE(MAX(seq),0)+1 FROM ai.messages WHERE conversation_id = ${task.conversation_id}::uuid),
|
|
407
|
+
'message', 'assistant', ${JSON.stringify(message)}::text::jsonb, ${resultText}, ${JSON.stringify(meta)}::text::jsonb)
|
|
408
|
+
`,
|
|
409
|
+
);
|
|
410
|
+
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`;
|
|
411
|
+
};
|
|
412
|
+
|
|
310
413
|
export const aiChatTasks = {
|
|
414
|
+
listActivities: async (input: { userId: string; limit?: number; offset?: number }) => {
|
|
415
|
+
const limit = Math.min(Math.max(input.limit ?? 50, 1), 100);
|
|
416
|
+
const rows = await sql<(OccurrenceRow & { unread: boolean })[]>`
|
|
417
|
+
SELECT occurrence.*, (occurrence.delivered_at > COALESCE(conversation.last_viewed_at, '-infinity')) AS unread
|
|
418
|
+
FROM ai.chat_task_occurrences occurrence
|
|
419
|
+
JOIN ai.chat_tasks task ON task.id = occurrence.task_id
|
|
420
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
421
|
+
WHERE task.sponsor_user_id = ${input.userId}::uuid AND conversation.created_by_user_id = ${input.userId}::uuid
|
|
422
|
+
AND conversation.archived_at IS NULL
|
|
423
|
+
ORDER BY occurrence.created_at DESC, occurrence.id DESC LIMIT ${limit + 1} OFFSET ${Math.max(input.offset ?? 0, 0)}
|
|
424
|
+
`;
|
|
425
|
+
const page = rows.slice(0, limit);
|
|
426
|
+
if (!page.length) return { items: [], hasMore: false };
|
|
427
|
+
const tasks = await sql<TaskRow[]>`${taskSelect()} WHERE task.id IN
|
|
428
|
+
(SELECT value::uuid FROM jsonb_array_elements_text(${JSON.stringify([...new Set(page.map((row) => row.task_id))])}::text::jsonb))`;
|
|
429
|
+
const byId = new Map(tasks.map((row) => [row.id, toTask(row)]));
|
|
430
|
+
return {
|
|
431
|
+
items: page.flatMap((row) => {
|
|
432
|
+
const task = byId.get(row.task_id);
|
|
433
|
+
return task
|
|
434
|
+
? [
|
|
435
|
+
{
|
|
436
|
+
task,
|
|
437
|
+
occurrence: toOccurrence(row),
|
|
438
|
+
chatTitle: task.chatTitle,
|
|
439
|
+
unread: Boolean(row.unread),
|
|
440
|
+
},
|
|
441
|
+
]
|
|
442
|
+
: [];
|
|
443
|
+
}),
|
|
444
|
+
hasMore: rows.length > limit,
|
|
445
|
+
};
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
getOccurrenceDetail: async (input: { userId: string; taskId: string; occurrenceId: string }) => {
|
|
449
|
+
const task = await aiChatTasks.get({ userId: input.userId, taskId: input.taskId });
|
|
450
|
+
if (!task) return null;
|
|
451
|
+
const [row] = await sql<
|
|
452
|
+
OccurrenceRow[]
|
|
453
|
+
>`SELECT * FROM ai.chat_task_occurrences WHERE task_id = ${task.id}::uuid AND short_id = ${input.occurrenceId}`;
|
|
454
|
+
if (!row) return null;
|
|
455
|
+
const messages = row.turn_id
|
|
456
|
+
? await aiConversations.listTurnMessages({ conversationId: task.conversationId, loopId: row.turn_id, includeCompacted: true })
|
|
457
|
+
: [];
|
|
458
|
+
return { task, occurrence: toOccurrence(row), messages };
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
validateGrants: validateTaskGrants,
|
|
462
|
+
authorizeCapability: async (input: {
|
|
463
|
+
mandate: { id: string; revision: number };
|
|
464
|
+
appId: string;
|
|
465
|
+
capabilityId: string;
|
|
466
|
+
kind: "query" | "action";
|
|
467
|
+
input: unknown;
|
|
468
|
+
approval?: "none" | "rememberable" | "always";
|
|
469
|
+
}): Promise<void> => {
|
|
470
|
+
const result = await validateMandateIssueAuthority({
|
|
471
|
+
mandateId: input.mandate.id,
|
|
472
|
+
expectedRevision: input.mandate.revision,
|
|
473
|
+
ownerAppId: "core",
|
|
474
|
+
targetAppId: input.appId,
|
|
475
|
+
operation: `capability.${input.kind === "query" ? "query" : "action.run"}:${input.capabilityId}`,
|
|
476
|
+
input: input.input,
|
|
477
|
+
capabilityApproval: input.approval,
|
|
478
|
+
});
|
|
479
|
+
if (!result.ok)
|
|
480
|
+
throw new AiChatTaskAuthorityError(
|
|
481
|
+
`Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
|
|
482
|
+
);
|
|
483
|
+
},
|
|
311
484
|
list: async (input: {
|
|
312
485
|
userId: string;
|
|
313
486
|
chatId?: string;
|
|
@@ -316,7 +489,7 @@ export const aiChatTasks = {
|
|
|
316
489
|
offset?: number;
|
|
317
490
|
}): Promise<AiChatTask[]> => {
|
|
318
491
|
const rows = await sql<TaskRow[]>`
|
|
319
|
-
${taskSelect}
|
|
492
|
+
${taskSelect()}
|
|
320
493
|
WHERE task.sponsor_user_id = ${input.userId}::uuid
|
|
321
494
|
AND (${input.chatId ?? null}::text IS NULL OR conversation.short_id = ${input.chatId ?? null})
|
|
322
495
|
AND (${input.state ?? null}::text IS NULL OR task.state = ${input.state ?? null})
|
|
@@ -329,7 +502,7 @@ export const aiChatTasks = {
|
|
|
329
502
|
|
|
330
503
|
get: async (input: { userId: string; taskId: string }): Promise<AiChatTask | null> => {
|
|
331
504
|
const rows = await sql<TaskRow[]>`
|
|
332
|
-
${taskSelect}
|
|
505
|
+
${taskSelect()}
|
|
333
506
|
WHERE task.sponsor_user_id = ${input.userId}::uuid
|
|
334
507
|
AND task.short_id = ${input.taskId}
|
|
335
508
|
LIMIT 1
|
|
@@ -343,6 +516,7 @@ export const aiChatTasks = {
|
|
|
343
516
|
prompt: string;
|
|
344
517
|
schedule: AiChatTaskSchedule;
|
|
345
518
|
timezone: string;
|
|
519
|
+
grants?: CapabilityGrant[];
|
|
346
520
|
idempotencyKey?: string;
|
|
347
521
|
idempotencyFingerprint?: string;
|
|
348
522
|
}): Promise<AiChatTask | null> => {
|
|
@@ -350,7 +524,7 @@ export const aiChatTasks = {
|
|
|
350
524
|
const fingerprint = scopedKey ? (input.idempotencyFingerprint ?? taskFingerprint(input)) : null;
|
|
351
525
|
if (input.idempotencyKey) {
|
|
352
526
|
const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
|
|
353
|
-
${taskSelect}
|
|
527
|
+
${taskSelect()}
|
|
354
528
|
WHERE task.idempotency_key = ${scopedKey}
|
|
355
529
|
AND task.sponsor_user_id = ${input.userId}::uuid
|
|
356
530
|
LIMIT 1
|
|
@@ -360,6 +534,7 @@ export const aiChatTasks = {
|
|
|
360
534
|
return toTask(existing[0]);
|
|
361
535
|
}
|
|
362
536
|
}
|
|
537
|
+
const grants = await validateTaskGrants(input.grants ?? []);
|
|
363
538
|
let rows: TaskRow[];
|
|
364
539
|
try {
|
|
365
540
|
rows = await sql.begin(async (tx) => {
|
|
@@ -380,7 +555,7 @@ export const aiChatTasks = {
|
|
|
380
555
|
ownerAppId: "core",
|
|
381
556
|
workloadType: "ai.chat-task",
|
|
382
557
|
workloadId: taskId,
|
|
383
|
-
policy:
|
|
558
|
+
policy: chatTaskMandatePolicy(grants),
|
|
384
559
|
},
|
|
385
560
|
{ db: tx },
|
|
386
561
|
);
|
|
@@ -405,6 +580,7 @@ export const aiChatTasks = {
|
|
|
405
580
|
);
|
|
406
581
|
const task = inserted[0];
|
|
407
582
|
if (!task) throw new AiChatTaskCreateRace();
|
|
583
|
+
task.mandate_policy = mandate.data.policy;
|
|
408
584
|
return inserted;
|
|
409
585
|
});
|
|
410
586
|
} catch (error) {
|
|
@@ -414,7 +590,7 @@ export const aiChatTasks = {
|
|
|
414
590
|
if (rows[0]) return toTask(rows[0]);
|
|
415
591
|
if (!input.idempotencyKey) return null;
|
|
416
592
|
const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
|
|
417
|
-
${taskSelect}
|
|
593
|
+
${taskSelect()}
|
|
418
594
|
WHERE task.idempotency_key = ${scopedKey}
|
|
419
595
|
AND task.sponsor_user_id = ${input.userId}::uuid
|
|
420
596
|
LIMIT 1
|
|
@@ -430,7 +606,7 @@ export const aiChatTasks = {
|
|
|
430
606
|
idempotencyFingerprint: string;
|
|
431
607
|
}): Promise<AiChatTask | null> => {
|
|
432
608
|
const rows = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
|
|
433
|
-
${taskSelect}
|
|
609
|
+
${taskSelect()}
|
|
434
610
|
WHERE task.idempotency_key = ${taskIdempotencyKey(input.idempotencyKey)}
|
|
435
611
|
AND task.sponsor_user_id = ${input.userId}::uuid
|
|
436
612
|
LIMIT 1
|
|
@@ -444,12 +620,13 @@ export const aiChatTasks = {
|
|
|
444
620
|
userId: string;
|
|
445
621
|
taskId: string;
|
|
446
622
|
prompt?: string;
|
|
623
|
+
grants?: CapabilityGrant[];
|
|
447
624
|
schedule?: AiChatTaskSchedule;
|
|
448
625
|
timezone?: string;
|
|
449
626
|
}): Promise<AiChatTask | null> =>
|
|
450
627
|
sql.begin(async (tx) => {
|
|
451
628
|
const rows = await tx<TaskRow[]>`
|
|
452
|
-
${taskSelect}
|
|
629
|
+
${taskSelect()}
|
|
453
630
|
WHERE task.sponsor_user_id = ${input.userId}::uuid
|
|
454
631
|
AND task.short_id = ${input.taskId}
|
|
455
632
|
FOR UPDATE OF task
|
|
@@ -460,7 +637,7 @@ export const aiChatTasks = {
|
|
|
460
637
|
const wasActive = current.state === "active";
|
|
461
638
|
const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
|
|
462
639
|
if (authorityStopped) {
|
|
463
|
-
[current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
|
|
640
|
+
[current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
|
|
464
641
|
if (!current) return null;
|
|
465
642
|
}
|
|
466
643
|
const nextTimezone = input.timezone ?? current.timezone;
|
|
@@ -470,10 +647,27 @@ export const aiChatTasks = {
|
|
|
470
647
|
nextTimezone !== current.timezone
|
|
471
648
|
: false;
|
|
472
649
|
const promptChanged = input.prompt !== undefined && input.prompt.trim() !== current.prompt;
|
|
473
|
-
|
|
650
|
+
const grants = input.grants === undefined ? undefined : await validateTaskGrants(input.grants);
|
|
651
|
+
let updatedMandateRevision = Number(current.mandate_revision);
|
|
652
|
+
if (grants !== undefined) {
|
|
653
|
+
const mandate = await loadTaskMandate(current, tx);
|
|
654
|
+
if (!mandate || !taskMandateMatches(current, mandate)) throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
|
|
655
|
+
const updated = await updateMandatePolicy(
|
|
656
|
+
{
|
|
657
|
+
mandateId: mandate.id,
|
|
658
|
+
expectedRevision: Number(mandate.revision),
|
|
659
|
+
authority: mandateAuthority(input.userId),
|
|
660
|
+
policy: chatTaskMandatePolicy(grants),
|
|
661
|
+
},
|
|
662
|
+
{ db: tx },
|
|
663
|
+
);
|
|
664
|
+
if (!updated.ok) throw mandateError("update", updated);
|
|
665
|
+
updatedMandateRevision = updated.data.revision;
|
|
666
|
+
}
|
|
667
|
+
if (!promptChanged && !scheduleChanged && grants === undefined) return toTask(current);
|
|
474
668
|
await tx`
|
|
475
669
|
UPDATE ai.chat_tasks task
|
|
476
|
-
SET prompt = ${input.prompt?.trim() ?? current.prompt},
|
|
670
|
+
SET mandate_revision = ${updatedMandateRevision}, prompt = ${input.prompt?.trim() ?? current.prompt},
|
|
477
671
|
schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
|
|
478
672
|
run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
|
|
479
673
|
cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
|
|
@@ -499,7 +693,7 @@ export const aiChatTasks = {
|
|
|
499
693
|
WHERE task_id = ${current.id}::uuid AND state = 'queued'
|
|
500
694
|
`;
|
|
501
695
|
}
|
|
502
|
-
const updated = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
|
|
696
|
+
const updated = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${current.id}::uuid`;
|
|
503
697
|
const next = updated[0]!;
|
|
504
698
|
if (next.state === "active" && !wasActive) {
|
|
505
699
|
const revision = await mandateRevision(requireTaskMandate(next), "active", input.userId, tx);
|
|
@@ -514,7 +708,7 @@ export const aiChatTasks = {
|
|
|
514
708
|
setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
|
|
515
709
|
return sql.begin(async (tx) => {
|
|
516
710
|
const rows = await tx<TaskRow[]>`
|
|
517
|
-
${taskSelect}
|
|
711
|
+
${taskSelect()}
|
|
518
712
|
WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
|
|
519
713
|
FOR UPDATE OF task
|
|
520
714
|
`;
|
|
@@ -555,7 +749,7 @@ export const aiChatTasks = {
|
|
|
555
749
|
delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
|
|
556
750
|
return sql.begin(async (tx) => {
|
|
557
751
|
const rows = await tx<TaskRow[]>`
|
|
558
|
-
${taskSelect}
|
|
752
|
+
${taskSelect()}
|
|
559
753
|
WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
|
|
560
754
|
FOR UPDATE OF task
|
|
561
755
|
`;
|
|
@@ -599,7 +793,7 @@ export const aiChatTasks = {
|
|
|
599
793
|
|
|
600
794
|
listActiveCron: async (): Promise<AiChatTask[]> => {
|
|
601
795
|
const rows = await sql<TaskRow[]>`
|
|
602
|
-
${taskSelect}
|
|
796
|
+
${taskSelect()}
|
|
603
797
|
WHERE task.state = 'active' AND task.schedule_kind = 'cron'
|
|
604
798
|
`;
|
|
605
799
|
const tasks: AiChatTask[] = [];
|
|
@@ -624,8 +818,14 @@ export const aiChatTasks = {
|
|
|
624
818
|
expectedRevision?: number;
|
|
625
819
|
}): Promise<AiChatTaskOccurrence | null> => {
|
|
626
820
|
return sql.begin(async (tx) => {
|
|
627
|
-
const [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
|
|
821
|
+
const [current] = await tx<TaskRow[]>`${taskSelect()} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
|
|
628
822
|
if (!current || !(await prepareTaskForExecution(current, tx))) return null;
|
|
823
|
+
// One occurrence per schedule; timer ticks during a run do not accumulate.
|
|
824
|
+
const [inFlight] = await tx<
|
|
825
|
+
OccurrenceRow[]
|
|
826
|
+
>`SELECT * FROM ai.chat_task_occurrences WHERE task_id = ${input.taskId}::uuid AND state IN ('queued','running') LIMIT 1`;
|
|
827
|
+
if (inFlight) return inFlight.request_key === input.requestKey ? toOccurrence(inFlight) : null;
|
|
828
|
+
|
|
629
829
|
const rows = await withAiShortIdForDb(
|
|
630
830
|
tx,
|
|
631
831
|
"ai_chat_task_occurrences_short_id_unique",
|
|
@@ -686,20 +886,20 @@ export const aiChatTasks = {
|
|
|
686
886
|
occurrence.state, occurrence.turn_id, occurrence.error, occurrence.created_at, occurrence.started_at, occurrence.completed_at,
|
|
687
887
|
task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
|
|
688
888
|
conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
|
|
889
|
+
(SELECT policy FROM auth.mandates WHERE id = task.mandate_id) AS mandate_policy,
|
|
689
890
|
task.prompt, task.schedule_kind, task.run_at, task.cron,
|
|
690
891
|
task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
|
|
691
892
|
FROM (
|
|
692
893
|
SELECT candidate.*,
|
|
693
|
-
row_number() OVER (PARTITION BY task.
|
|
894
|
+
row_number() OVER (PARTITION BY task.id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
|
|
694
895
|
FROM ai.chat_task_occurrences candidate
|
|
695
896
|
JOIN ai.chat_tasks task ON task.id = candidate.task_id
|
|
696
897
|
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
697
898
|
WHERE candidate.state = 'queued' AND task.state = 'active'
|
|
698
899
|
AND task.mandate_id IS NOT NULL AND task.mandate_revision IS NOT NULL
|
|
699
900
|
AND NOT EXISTS (
|
|
700
|
-
SELECT 1 FROM ai.
|
|
701
|
-
WHERE
|
|
702
|
-
AND turn.status IN ('queued', 'running', 'waiting_for_action')
|
|
901
|
+
SELECT 1 FROM ai.chat_task_occurrences running
|
|
902
|
+
WHERE running.task_id = task.id AND running.state = 'running'
|
|
703
903
|
)
|
|
704
904
|
) occurrence
|
|
705
905
|
JOIN ai.chat_tasks task ON task.id = occurrence.task_id
|
|
@@ -728,7 +928,7 @@ export const aiChatTasks = {
|
|
|
728
928
|
const occurrence = occurrences[0];
|
|
729
929
|
if (!occurrence) return null;
|
|
730
930
|
const tasks = await tx<TaskRow[]>`
|
|
731
|
-
${taskSelect}
|
|
931
|
+
${taskSelect()}
|
|
732
932
|
WHERE task.id = ${occurrence.task_id}::uuid AND task.state = 'active'
|
|
733
933
|
LIMIT 1
|
|
734
934
|
FOR UPDATE OF task
|
|
@@ -759,6 +959,7 @@ export const aiChatTasks = {
|
|
|
759
959
|
RETURNING occurrence.task_id, occurrence.task_revision
|
|
760
960
|
`;
|
|
761
961
|
if (rows[0]) {
|
|
962
|
+
await deliverOccurrenceResult(tx, task, input.occurrenceId, input.error);
|
|
762
963
|
const revision = await pauseTerminalTaskMandate(task, tx);
|
|
763
964
|
await tx`
|
|
764
965
|
UPDATE ai.chat_tasks
|
|
@@ -821,15 +1022,26 @@ export const aiChatTasks = {
|
|
|
821
1022
|
if (occurrence.archived_at) return { delivered: false as const, reason: "not_found" as const };
|
|
822
1023
|
if (Number(occurrence.task_revision) !== input.expectedRevision) return { delivered: false as const, reason: "stale" as const };
|
|
823
1024
|
const active = await tx<{ id: string }[]>`
|
|
824
|
-
SELECT id FROM ai.
|
|
825
|
-
WHERE
|
|
826
|
-
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1025
|
+
SELECT id FROM ai.chat_task_occurrences
|
|
1026
|
+
WHERE task_id = ${task.id}::uuid AND state = 'running'
|
|
827
1027
|
LIMIT 1
|
|
828
1028
|
`;
|
|
829
1029
|
if (active[0]) return { delivered: false as const, reason: "busy" as const };
|
|
830
1030
|
const mandate = requireTaskMandate(task);
|
|
1031
|
+
const contextRows = await tx<{ seq: number; kind: "message" | "summary"; message: Message }[]>`
|
|
1032
|
+
SELECT message.seq, message.kind, message.message FROM ai.messages message
|
|
1033
|
+
WHERE message.conversation_id = ${occurrence.conversation_id}::uuid AND message.compacted_at IS NULL
|
|
1034
|
+
AND NOT EXISTS (SELECT 1 FROM ai.turns pending WHERE pending.id::text = message.loop_id
|
|
1035
|
+
AND pending.status IN ('queued','running','waiting_for_action'))
|
|
1036
|
+
ORDER BY message.seq
|
|
1037
|
+
`;
|
|
831
1038
|
const runConfig: AiChatTurnRunConfig = {
|
|
832
1039
|
...input.runConfig,
|
|
1040
|
+
background: {
|
|
1041
|
+
taskId: task.short_id,
|
|
1042
|
+
occurrenceId: occurrence.short_id,
|
|
1043
|
+
context: contextRows.map((row, index) => ({ seq: index + 1, kind: row.kind, message: row.message })),
|
|
1044
|
+
},
|
|
833
1045
|
mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
|
|
834
1046
|
};
|
|
835
1047
|
const turn = await withAiShortIdForDb(
|
|
@@ -854,16 +1066,16 @@ export const aiChatTasks = {
|
|
|
854
1066
|
tx,
|
|
855
1067
|
"idx_ai_messages_conversation_short_id",
|
|
856
1068
|
(attempt, shortId) => attempt`
|
|
857
|
-
INSERT INTO ai.
|
|
1069
|
+
INSERT INTO ai.task_messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
|
|
858
1070
|
VALUES (
|
|
859
1071
|
${shortId}, ${occurrence.conversation_id}::uuid,
|
|
860
|
-
|
|
1072
|
+
1,
|
|
861
1073
|
'message', 'user', (${JSON.stringify(input.userMessage)}::text)::jsonb, ${input.runConfig.input}, ${turnId}::uuid,
|
|
862
1074
|
(${JSON.stringify(messageMeta)}::text)::jsonb
|
|
863
1075
|
)
|
|
864
1076
|
`,
|
|
865
1077
|
);
|
|
866
|
-
|
|
1078
|
+
|
|
867
1079
|
await tx`
|
|
868
1080
|
UPDATE ai.chat_task_occurrences
|
|
869
1081
|
SET state = 'running', turn_id = ${turnId}::uuid, task_revision = ${occurrence.task_revision}::bigint, started_at = now()
|
|
@@ -908,6 +1120,27 @@ export const aiChatTasks = {
|
|
|
908
1120
|
`;
|
|
909
1121
|
const row = rows[0];
|
|
910
1122
|
if (!row) return null;
|
|
1123
|
+
const responses = await tx<{ message: Message }[]>`
|
|
1124
|
+
SELECT message FROM ai.task_messages WHERE loop_id = ${input.turnId} AND role = 'assistant' ORDER BY seq DESC
|
|
1125
|
+
`;
|
|
1126
|
+
const resultText =
|
|
1127
|
+
responses
|
|
1128
|
+
.map((response) => {
|
|
1129
|
+
const message = response.message;
|
|
1130
|
+
if (message.role !== "assistant" || message.content.some((part) => part.type === "tool_call")) return "";
|
|
1131
|
+
return message.content
|
|
1132
|
+
.filter((part) => typeof part === "string" || part.type === "text")
|
|
1133
|
+
.map((part) => (typeof part === "string" ? part : part.type === "text" ? part.text : ""))
|
|
1134
|
+
.join("\n")
|
|
1135
|
+
.trim();
|
|
1136
|
+
})
|
|
1137
|
+
.find(Boolean) ||
|
|
1138
|
+
input.error ||
|
|
1139
|
+
row.turn_error ||
|
|
1140
|
+
(input.status === "completed"
|
|
1141
|
+
? "Background task completed."
|
|
1142
|
+
: "Background task could not finish. Please review this run in the chat.");
|
|
1143
|
+
await deliverOccurrenceResult(tx, task, row.occurrence_id, resultText);
|
|
911
1144
|
const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
|
|
912
1145
|
const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
|
|
913
1146
|
if (
|