@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/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { CODE_SOURCE_TOOLS, type CodeSourceToolName } from "./code-source-contracts";
|
|
2
|
+
export { CodeResourceId, CODE_RUNTIME_TOOL_NAMES, CodeRuntimeInput, parseCodeToolInput } from "./browser-code-contracts";
|
|
1
3
|
export { assistantAiSettingsState, listAssistantAiModels } from "./assistant-models";
|
|
2
4
|
export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
|
|
3
5
|
export {
|
|
@@ -19,6 +21,7 @@ export {
|
|
|
19
21
|
type AiChatTaskView,
|
|
20
22
|
AiConversationIdSchema,
|
|
21
23
|
ChatTaskIdSchema,
|
|
24
|
+
ChatTaskGrantsSchema,
|
|
22
25
|
ChatTaskOccurrenceIdSchema,
|
|
23
26
|
ChatTaskScheduleInputSchema,
|
|
24
27
|
chatTaskCreateFingerprint,
|
|
@@ -72,6 +75,9 @@ export {
|
|
|
72
75
|
type AiFileStat,
|
|
73
76
|
guessAiMediaType,
|
|
74
77
|
listAiConversationFiles,
|
|
78
|
+
readAiConversationFile,
|
|
79
|
+
createAiConversationArtifact,
|
|
80
|
+
writeAiConversationFile,
|
|
75
81
|
normalizeAiFilePath,
|
|
76
82
|
} from "./files-store";
|
|
77
83
|
export {
|
|
@@ -214,7 +220,7 @@ export {
|
|
|
214
220
|
selectAiModelProfile,
|
|
215
221
|
toPublicAiSettingsState,
|
|
216
222
|
} from "./settings";
|
|
217
|
-
export { AI_SHORT_ID_PATTERN, createAiShortId } from "./short-id";
|
|
223
|
+
export { AI_SHORT_ID_PATTERN, createAiShortId, withAiShortIdForDb } from "./short-id";
|
|
218
224
|
export {
|
|
219
225
|
AI_SKILL_DESCRIPTION_MAX_CHARS,
|
|
220
226
|
AI_SKILL_EXTRA_FRONTMATTER_MAX_CHARS,
|
|
@@ -237,7 +243,7 @@ export {
|
|
|
237
243
|
validateAiSkillName,
|
|
238
244
|
validateAiSkillReferences,
|
|
239
245
|
} from "./skill-format";
|
|
240
|
-
export { seedCloudAiSkills } from "./
|
|
246
|
+
export { seedCloudAiSkills } from "./skills";
|
|
241
247
|
export {
|
|
242
248
|
type AiLoadedSkillSnapshot,
|
|
243
249
|
type AiSkill,
|
|
@@ -335,3 +341,21 @@ export {
|
|
|
335
341
|
type CloudAiViewImageOutput,
|
|
336
342
|
CloudAiViewImageOutputSchema,
|
|
337
343
|
} from "./vision-tool";
|
|
344
|
+
|
|
345
|
+
export {
|
|
346
|
+
createAiTranscriptionProvider,
|
|
347
|
+
resolveAiAudioModel,
|
|
348
|
+
runAiTranscription,
|
|
349
|
+
type AiResolvedAudioModel,
|
|
350
|
+
type RunAiTranscriptionInput,
|
|
351
|
+
} from "./transcription";
|
|
352
|
+
|
|
353
|
+
export { createCloudAiTranscribeAudioTool, CloudAiTranscribeAudioInputSchema, CloudAiTranscribeAudioOutputSchema } from "./audio-tool";
|
|
354
|
+
|
|
355
|
+
export { getAiChatQuotas } from "./chat-quotas";
|
|
356
|
+
|
|
357
|
+
export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
|
|
358
|
+
export { aiFileContentVersion, AiFileWriteError, AiFileVersionConflict } from "./file-content-version";
|
|
359
|
+
|
|
360
|
+
// Framework-owned managed execution host transport.
|
|
361
|
+
export { createCodeCapabilityTransport, CODE_CAPABILITY_TOKEN_HEADER } from "./code-capability-transport";
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { hasBillableAiPricing } from "../shared/ai-costs";
|
|
2
|
+
import { sql, type SQL } from "bun";
|
|
3
|
+
import type { AccessSubject } from "../server/services/access";
|
|
4
|
+
import { aiCostDecimal, aiCostUnits, aiDecimalUnits, aiBudgetUnits } from "../shared/ai-costs";
|
|
5
|
+
import type { AiModelProfile } from "./types";
|
|
6
|
+
import { aiQuotas, AiQuotaError, quotaWindow } from "./quotas";
|
|
7
|
+
|
|
8
|
+
export class AiBackgroundCostError extends Error {
|
|
9
|
+
readonly code = "ai_background_cost_stop";
|
|
10
|
+
constructor() {
|
|
11
|
+
super("Background AI is paused by the cost safety limit. An administrator must review Usage rules.");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Admission can fail without latching the instance-wide emergency stop. */
|
|
16
|
+
export class AiBackgroundAdmissionError extends Error {
|
|
17
|
+
readonly code: string;
|
|
18
|
+
constructor(readonly retryable: boolean) {
|
|
19
|
+
super(
|
|
20
|
+
retryable
|
|
21
|
+
? "Background AI budget is reserved by running calls. Try again after they finish."
|
|
22
|
+
: "This call cannot fit in the remaining background AI budget.",
|
|
23
|
+
);
|
|
24
|
+
this.code = retryable ? "ai_background_budget_reserved" : "ai_background_budget_insufficient";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AiCallContext = {
|
|
29
|
+
kind: "chat" | "background";
|
|
30
|
+
task: string;
|
|
31
|
+
subject?: AccessSubject | null;
|
|
32
|
+
userId?: string;
|
|
33
|
+
conversationId?: string;
|
|
34
|
+
turnId?: string;
|
|
35
|
+
workflowRunId?: string;
|
|
36
|
+
stepKey?: string;
|
|
37
|
+
traceId?: string;
|
|
38
|
+
appId?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export async function backgroundCostState(db: SQL = sql) {
|
|
42
|
+
const [row] = await db<
|
|
43
|
+
{ used: number; reserved: number; usedExact: string; reservedExact: string; unknown: number; stoppedAt: Date | null; unit: string }[]
|
|
44
|
+
>`
|
|
45
|
+
SELECT COALESCE(sum(cost),0)::text AS "usedExact",
|
|
46
|
+
COALESCE(sum(reserved) FILTER(WHERE finished_at IS NULL AND lease_expires_at>now()),0)::text AS "reservedExact",
|
|
47
|
+
COALESCE(sum(cost),0)::float8 AS used,
|
|
48
|
+
COALESCE(sum(reserved) FILTER(WHERE finished_at IS NULL AND lease_expires_at>now()),0)::float8 AS reserved,
|
|
49
|
+
count(*) FILTER(WHERE (COALESCE((pricing->>'inputPerMillion')::numeric,0)>0 OR COALESCE((pricing->>'outputPerMillion')::numeric,0)>0) AND cost IS NULL AND (finished_at IS NOT NULL OR lease_expires_at<=now()))::int AS unknown,
|
|
50
|
+
(SELECT background_stopped_at FROM ai.cost_config WHERE singleton) AS "stoppedAt",
|
|
51
|
+
(SELECT unit FROM ai.cost_config WHERE singleton) AS unit
|
|
52
|
+
FROM ai.inference_calls WHERE kind='background' AND started_at>=now()-interval '24 hours'`;
|
|
53
|
+
return row!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Called under the configuration lock; persist transitions before notification delivery. */
|
|
57
|
+
async function checkBackgroundBudget(db: SQL) {
|
|
58
|
+
const config = await aiQuotas.config(db);
|
|
59
|
+
const state = await backgroundCostState(db);
|
|
60
|
+
if (!config.background?.enabled) return state;
|
|
61
|
+
const [flags] = await db<{ background_warned: boolean }[]>`SELECT background_warned FROM ai.cost_config WHERE singleton`;
|
|
62
|
+
const warning = config.background.warnAt !== null && aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.warnAt);
|
|
63
|
+
const stop = aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.stopAt) || state.unknown > 0;
|
|
64
|
+
for (const kind of [...(warning && !flags!.background_warned ? ["warning"] : []), ...(stop && !state.stoppedAt ? ["stop"] : [])])
|
|
65
|
+
await db`INSERT INTO ai.cost_alerts(id,kind,cost,unit) VALUES(${crypto.randomUUID()}::uuid,${kind},${state.usedExact}::numeric,${config.unit ?? "EUR"})`;
|
|
66
|
+
await db`UPDATE ai.cost_config SET background_warned=${warning},
|
|
67
|
+
background_stopped_at=CASE WHEN ${stop} THEN COALESCE(background_stopped_at,clock_timestamp()) ELSE background_stopped_at END WHERE singleton`;
|
|
68
|
+
return backgroundCostState(db);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Admission and reservation share the settings lock across all worker processes. */
|
|
72
|
+
export async function beginAiCall(
|
|
73
|
+
profile: AiModelProfile,
|
|
74
|
+
context: AiCallContext,
|
|
75
|
+
inputTokens: number,
|
|
76
|
+
requestedOutput?: number,
|
|
77
|
+
outputBound?: number,
|
|
78
|
+
) {
|
|
79
|
+
const result = await sql.begin(async (db) => {
|
|
80
|
+
await db`SELECT singleton FROM ai.cost_config WHERE singleton FOR UPDATE`;
|
|
81
|
+
const config = await aiQuotas.config(db);
|
|
82
|
+
let remaining: bigint | undefined;
|
|
83
|
+
let backgroundHeadroom: bigint | undefined;
|
|
84
|
+
if (hasBillableAiPricing(profile.pricing) && context.kind === "background" && config.background?.enabled) {
|
|
85
|
+
const state = await checkBackgroundBudget(db);
|
|
86
|
+
if (state.stoppedAt || aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.stopAt) || state.unknown) {
|
|
87
|
+
await db`UPDATE ai.cost_config SET background_stopped_at=COALESCE(background_stopped_at,clock_timestamp()) WHERE singleton`;
|
|
88
|
+
return { error: new AiBackgroundCostError() };
|
|
89
|
+
}
|
|
90
|
+
backgroundHeadroom = aiBudgetUnits(config.background.stopAt) - aiDecimalUnits(state.usedExact);
|
|
91
|
+
remaining = backgroundHeadroom - aiDecimalUnits(state.reservedExact);
|
|
92
|
+
}
|
|
93
|
+
if (hasBillableAiPricing(profile.pricing) && context.kind === "chat" && context.subject && config.enabled) {
|
|
94
|
+
const now = new Date();
|
|
95
|
+
const snapshot = await aiQuotas.snapshot(context.subject, profile.id, now, db);
|
|
96
|
+
for (const balance of snapshot.balances) {
|
|
97
|
+
if (balance.bypassed || balance.limit === null) continue;
|
|
98
|
+
if (balance.unknown)
|
|
99
|
+
return {
|
|
100
|
+
error: new AiQuotaError(
|
|
101
|
+
"quota_usage_unknown",
|
|
102
|
+
"Chat costs could not be measured. Reset the allowance or wait for the next window.",
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
const rule = config.rules.find((rule) => rule.scope === balance.scope)!;
|
|
106
|
+
const window = quotaWindow(rule.anchor, rule.hours, now);
|
|
107
|
+
const user = context.subject.type === "user" ? context.subject.userId : null;
|
|
108
|
+
const service = context.subject.type === "service_account" ? context.subject.serviceAccountId : null;
|
|
109
|
+
const [pending] = await db<{ committed: string }[]>`
|
|
110
|
+
SELECT (COALESCE(sum(cost),0)+COALESCE(sum(reserved) FILTER(WHERE finished_at IS NULL AND lease_expires_at>now()),0))::text AS committed FROM ai.inference_calls c
|
|
111
|
+
WHERE kind='chat'
|
|
112
|
+
AND user_id IS NOT DISTINCT FROM ${user}::uuid AND service_account_id IS NOT DISTINCT FROM ${service}::uuid
|
|
113
|
+
AND (${balance.scope}='*' OR model_profile_id=${balance.scope}) AND started_at>=${window.from} AND started_at<${window.until}
|
|
114
|
+
AND started_at>COALESCE((SELECT max(created_at) FROM ai.cost_resets r
|
|
115
|
+
WHERE r.user_id IS NOT DISTINCT FROM ${user}::uuid AND r.service_account_id IS NOT DISTINCT FROM ${service}::uuid AND r.scope=${balance.scope}),'-infinity'::timestamptz)`;
|
|
116
|
+
const available = aiBudgetUnits(balance.limit) - aiDecimalUnits(pending?.committed ?? "0");
|
|
117
|
+
remaining = remaining === undefined || available < remaining ? available : remaining;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
let maxOutputTokens = requestedOutput;
|
|
121
|
+
let reserved = "0";
|
|
122
|
+
if (profile.pricing && remaining !== undefined) {
|
|
123
|
+
const inputCost = aiCostUnits(profile.pricing, inputTokens, 0);
|
|
124
|
+
const outputPrice = aiCostUnits(profile.pricing, 0, 1);
|
|
125
|
+
const affordable = outputPrice > 0n ? Number((remaining - inputCost) / outputPrice) : Infinity;
|
|
126
|
+
if (inputCost > remaining || affordable < 1)
|
|
127
|
+
return {
|
|
128
|
+
error:
|
|
129
|
+
context.kind === "background"
|
|
130
|
+
? new AiBackgroundAdmissionError(backgroundHeadroom !== undefined && inputCost + outputPrice <= backgroundHeadroom)
|
|
131
|
+
: new AiQuotaError("quota_exhausted", "Chat cost limit reached."),
|
|
132
|
+
};
|
|
133
|
+
const reserveOutput = Math.min(requestedOutput ?? outputBound ?? affordable, affordable);
|
|
134
|
+
// Preserve adapter defaults unless an explicit maximum or budget requires a cap.
|
|
135
|
+
if (requestedOutput !== undefined || reserveOutput < (outputBound ?? Infinity)) maxOutputTokens = reserveOutput;
|
|
136
|
+
reserved = aiCostDecimal(aiCostUnits(profile.pricing, inputTokens, Number.isFinite(reserveOutput) ? reserveOutput : 0));
|
|
137
|
+
}
|
|
138
|
+
const workflow = context.workflowRunId
|
|
139
|
+
? (
|
|
140
|
+
await db<{ id: string; name: string; app: string; revision: number }[]>`
|
|
141
|
+
SELECT w.id,w.name,r.app_id AS app,v.revision FROM workflows.run r
|
|
142
|
+
JOIN workflows.workflow w ON w.id=r.workflow_id JOIN workflows.version v ON v.id=r.workflow_version_id
|
|
143
|
+
WHERE r.id=${context.workflowRunId}::uuid`
|
|
144
|
+
)[0]
|
|
145
|
+
: undefined;
|
|
146
|
+
const id = crypto.randomUUID();
|
|
147
|
+
const userId = context.subject?.type === "user" ? context.subject.userId : context.userId;
|
|
148
|
+
const serviceId = context.subject?.type === "service_account" ? context.subject.serviceAccountId : null;
|
|
149
|
+
await db`INSERT INTO ai.inference_calls(id,user_id,service_account_id,model_profile_id,provider_model,kind,task,
|
|
150
|
+
turn_id,turn_attempt,conversation_id,workflow_run_id,step_key,trace_id,app_id,workflow_id,workflow_name,workflow_version,pricing,reserved,lease_expires_at)
|
|
151
|
+
VALUES(${id}::uuid,CASE WHEN ${serviceId}::uuid IS NULL THEN COALESCE(${userId ?? null}::uuid,(SELECT created_by_user_id FROM ai.conversations WHERE id=${context.conversationId ?? null}::uuid)) ELSE NULL END,
|
|
152
|
+
${serviceId}::uuid,${profile.id},${profile.model},${context.kind},${context.task},${context.turnId ?? null}::uuid,
|
|
153
|
+
(SELECT attempt FROM ai.turns WHERE id=${context.turnId ?? null}::uuid),${context.conversationId ?? null}::uuid,
|
|
154
|
+
${context.workflowRunId ?? null}::uuid,${context.stepKey ?? null},${context.traceId ?? null},COALESCE(${workflow?.app ?? context.appId ?? null},(SELECT launched_by_app_id FROM ai.conversations WHERE id=${context.conversationId ?? null}::uuid),'core'),${workflow?.id ?? null}::uuid,${workflow?.name ?? null},${workflow?.revision ?? null},
|
|
155
|
+
(${profile.pricing ? JSON.stringify(profile.pricing) : null}::text)::jsonb,${reserved}::numeric,now()+interval '2 minutes')`;
|
|
156
|
+
return { id, maxOutputTokens };
|
|
157
|
+
});
|
|
158
|
+
if (result.error) throw result.error;
|
|
159
|
+
return { id: result.id!, maxOutputTokens: result.maxOutputTokens };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function finishAiCall(
|
|
163
|
+
id: string,
|
|
164
|
+
usage: { input: number; output: number; estimated?: boolean } | undefined,
|
|
165
|
+
status: "ok" | "failed",
|
|
166
|
+
) {
|
|
167
|
+
await sql.begin(async (db) => {
|
|
168
|
+
await db`SELECT singleton FROM ai.cost_config WHERE singleton FOR UPDATE`;
|
|
169
|
+
const [call] = await db<
|
|
170
|
+
{ pricing: AiModelProfile["pricing"]; kind: string }[]
|
|
171
|
+
>`SELECT pricing,kind FROM ai.inference_calls WHERE id=${id}::uuid AND finished_at IS NULL FOR UPDATE`;
|
|
172
|
+
if (!call) return;
|
|
173
|
+
const cost =
|
|
174
|
+
call.pricing?.inputPerMillion === 0 && call.pricing.outputPerMillion === 0
|
|
175
|
+
? "0"
|
|
176
|
+
: call.pricing && usage
|
|
177
|
+
? aiCostDecimal(aiCostUnits(call.pricing, usage.input, usage.output))
|
|
178
|
+
: null;
|
|
179
|
+
await db`UPDATE ai.inference_calls SET input=${usage?.input ?? null},output=${usage?.output ?? null},estimated=${usage?.estimated ?? false},
|
|
180
|
+
cost=${cost}::numeric,reserved=0,finished_at=clock_timestamp(),status=${status},error_code=${status === "failed" ? "ai_provider_call_failed" : null} WHERE id=${id}::uuid`;
|
|
181
|
+
if (call.kind === "background") await checkBackgroundBudget(db);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function releaseBackgroundCostStop(actorId: string) {
|
|
186
|
+
return sql.begin(async (db) => {
|
|
187
|
+
await db`SELECT singleton FROM ai.cost_config WHERE singleton FOR UPDATE`;
|
|
188
|
+
const config = await aiQuotas.config(db);
|
|
189
|
+
const state = await backgroundCostState(db);
|
|
190
|
+
if (config.background?.enabled && (aiDecimalUnits(state.usedExact) >= aiBudgetUnits(config.background.stopAt) || state.unknown))
|
|
191
|
+
throw new AiBackgroundCostError();
|
|
192
|
+
if (!state.stoppedAt) return state;
|
|
193
|
+
await db`UPDATE ai.cost_config SET background_stopped_at=NULL,background_warned=false,revision=revision+1 WHERE singleton`;
|
|
194
|
+
const next = await aiQuotas.config(db);
|
|
195
|
+
await db`INSERT INTO ai.cost_changes(revision,actor_id,config) VALUES(${next.revision},${actorId}::uuid,(${JSON.stringify({ ...next, action: "release-background-stop" })}::text)::jsonb)`;
|
|
196
|
+
return backgroundCostState(db);
|
|
197
|
+
});
|
|
198
|
+
}
|
package/src/ai/live-events.ts
CHANGED
|
@@ -7,6 +7,7 @@ export const AI_INVALIDATION_DOMAINS = [
|
|
|
7
7
|
"conversation-sources",
|
|
8
8
|
"conversation-files",
|
|
9
9
|
"conversation-tasks",
|
|
10
|
+
"conversation-dictations",
|
|
10
11
|
"project-list",
|
|
11
12
|
"project-detail",
|
|
12
13
|
"project-context",
|
|
@@ -129,7 +130,10 @@ const AiConversationSchema = z
|
|
|
129
130
|
keywords: z.array(z.string()),
|
|
130
131
|
pinnedAt: z.string().nullable(),
|
|
131
132
|
archivedAt: z.string().nullable(),
|
|
132
|
-
|
|
133
|
+
done: z.boolean().nullable(),
|
|
134
|
+
isDone: z.boolean(),
|
|
135
|
+
lastUsedAt: z.string(),
|
|
136
|
+
runStatus: z.enum(["idle", "queued", "running", "needs_attention", "waiting_for_browser", "failed"]),
|
|
133
137
|
runError: z.string().nullable(),
|
|
134
138
|
unreadCompletion: z.boolean(),
|
|
135
139
|
projectId: z.string().nullable(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AiBackgroundAdmissionError, AiBackgroundCostError } from "./inference-calls";
|
|
1
2
|
import { sql } from "bun";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -217,13 +218,7 @@ export const buildMemoryLearningTranscript = (messages: AiStoredMessage[]): stri
|
|
|
217
218
|
messages
|
|
218
219
|
.flatMap((stored) => {
|
|
219
220
|
const message = stored.message;
|
|
220
|
-
if (
|
|
221
|
-
stored.kind !== "message" ||
|
|
222
|
-
message.role !== "user" ||
|
|
223
|
-
stored.meta?.agentMessage ||
|
|
224
|
-
stored.meta?.scheduledTask
|
|
225
|
-
)
|
|
226
|
-
return [];
|
|
221
|
+
if (stored.kind !== "message" || message.role !== "user" || stored.meta?.agentMessage || stored.meta?.scheduledTask) return [];
|
|
227
222
|
return message.content.flatMap((part) => {
|
|
228
223
|
if (typeof part === "string") return part.trim() ? [part.trim()] : [];
|
|
229
224
|
if (part.type !== "text") return [];
|
|
@@ -305,7 +300,15 @@ const estimatedTokens = (systemPrompt: string, taskInput: string): number =>
|
|
|
305
300
|
|
|
306
301
|
const workflowEvidenceKey = (pattern: AiMemoryWorkflowPattern): string =>
|
|
307
302
|
createHash("sha256")
|
|
308
|
-
.update(
|
|
303
|
+
.update(
|
|
304
|
+
JSON.stringify([
|
|
305
|
+
pattern.userId,
|
|
306
|
+
pattern.capabilityId,
|
|
307
|
+
pattern.resourceRef.type,
|
|
308
|
+
pattern.resourceRef.id,
|
|
309
|
+
...[...pattern.turnIds].sort(),
|
|
310
|
+
]),
|
|
311
|
+
)
|
|
309
312
|
.digest("hex");
|
|
310
313
|
|
|
311
314
|
const toProposal = (change: z.infer<typeof AiMemoryLearningChangeSchema>): AiBackgroundMemoryProposal => ({
|
|
@@ -329,7 +332,8 @@ const applyChanges = async (
|
|
|
329
332
|
!evidence.actions.some(
|
|
330
333
|
(action) => action.resourceRef.type === change.resourceRef?.type && action.resourceRef.id === change.resourceRef.id,
|
|
331
334
|
)
|
|
332
|
-
)
|
|
335
|
+
)
|
|
336
|
+
continue;
|
|
333
337
|
const results = await aiMemories.applyBackgroundProposal({
|
|
334
338
|
userId: evidence.candidate.userId,
|
|
335
339
|
sourceConversationId: evidence.candidate.conversationId,
|
|
@@ -376,9 +380,7 @@ const workflowPatternInput = async (
|
|
|
376
380
|
return {
|
|
377
381
|
source: evidence[0] ?? null,
|
|
378
382
|
mutableMemoryIds: new Set(
|
|
379
|
-
selected.memories
|
|
380
|
-
.filter((memory) => memory.source === "background" && memory.priority === "normal")
|
|
381
|
-
.map((memory) => memory.shortId),
|
|
383
|
+
selected.memories.filter((memory) => memory.source === "background" && memory.priority === "normal").map((memory) => memory.shortId),
|
|
382
384
|
),
|
|
383
385
|
input: [
|
|
384
386
|
`CAPABILITY: ${pattern.capabilityId}`,
|
|
@@ -395,7 +397,8 @@ const workflowPatternInput = async (
|
|
|
395
397
|
const usageNumbers = (usage: RunAiStructuredResult<z.ZodType>["usage"], reserved: number) => {
|
|
396
398
|
const inputTokens = usage?.input;
|
|
397
399
|
const outputTokens = usage?.output;
|
|
398
|
-
const total =
|
|
400
|
+
const total =
|
|
401
|
+
usage?.total ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
|
|
399
402
|
return { inputTokens, outputTokens, accountedTokens: total ?? reserved };
|
|
400
403
|
};
|
|
401
404
|
|
|
@@ -414,8 +417,7 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
414
417
|
}
|
|
415
418
|
|
|
416
419
|
const configuredBudget =
|
|
417
|
-
input.deps?.monthlyTokenBudget ??
|
|
418
|
-
Number(await coreSettings.get<number>(AI_MEMORY_LEARNING_MONTHLY_TOKEN_BUDGET_SETTING_KEY));
|
|
420
|
+
input.deps?.monthlyTokenBudget ?? Number(await coreSettings.get<number>(AI_MEMORY_LEARNING_MONTHLY_TOKEN_BUDGET_SETTING_KEY));
|
|
419
421
|
const monthlyTokenBudget =
|
|
420
422
|
Number.isFinite(configuredBudget) && configuredBudget > 0
|
|
421
423
|
? Math.floor(configuredBudget)
|
|
@@ -432,9 +434,7 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
432
434
|
let defaultLocale: Promise<string> | undefined;
|
|
433
435
|
const resolveLearningLocale = (locale?: string | null): Promise<string> => {
|
|
434
436
|
if (locale) return Promise.resolve(normalizeLocale(locale));
|
|
435
|
-
defaultLocale ??= (input.deps?.readDefaultLocale ?? (() => coreSettings.get<string>("app.locale")))().then(
|
|
436
|
-
normalizeLocale,
|
|
437
|
-
);
|
|
437
|
+
defaultLocale ??= (input.deps?.readDefaultLocale ?? (() => coreSettings.get<string>("app.locale")))().then(normalizeLocale);
|
|
438
438
|
return defaultLocale;
|
|
439
439
|
};
|
|
440
440
|
|
|
@@ -514,6 +514,18 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
514
514
|
if (status === "skipped") summary.skipped += 1;
|
|
515
515
|
processedCandidates += 1;
|
|
516
516
|
} catch (error) {
|
|
517
|
+
if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
|
|
518
|
+
summary.skipped += 1;
|
|
519
|
+
if (runId)
|
|
520
|
+
await aiMemoryLearningRuns.finish({
|
|
521
|
+
runId,
|
|
522
|
+
status: "skipped",
|
|
523
|
+
durationMs: Date.now() - startedAt,
|
|
524
|
+
changes: [],
|
|
525
|
+
accountedTokens: 0,
|
|
526
|
+
});
|
|
527
|
+
return summary;
|
|
528
|
+
}
|
|
517
529
|
summary.failed += 1;
|
|
518
530
|
processedCandidates += 1;
|
|
519
531
|
await markFailed(candidate).catch(() => undefined);
|
|
@@ -564,7 +576,11 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
564
576
|
if (!runId) continue;
|
|
565
577
|
const result = await structured({
|
|
566
578
|
task: "memory-learn-workflow",
|
|
567
|
-
attribution: {
|
|
579
|
+
attribution: {
|
|
580
|
+
userId: pattern.userId,
|
|
581
|
+
conversationId: context.source.candidate.conversationId,
|
|
582
|
+
turnId: context.source.candidate.turnId,
|
|
583
|
+
},
|
|
568
584
|
appId: "ai",
|
|
569
585
|
systemPrompt: workflowPrompt,
|
|
570
586
|
input: context.input,
|
|
@@ -578,15 +594,19 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
578
594
|
if (result.output.workflow && (await learningEnabled(pattern.userId))) {
|
|
579
595
|
const memoryIds = result.output.workflow.memoryIds;
|
|
580
596
|
const action = memoryIds.length > 1 ? "merge" : memoryIds.length === 1 ? "replace" : "add";
|
|
581
|
-
changes = await applyChanges(
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
597
|
+
changes = await applyChanges(
|
|
598
|
+
context.source,
|
|
599
|
+
[
|
|
600
|
+
{
|
|
601
|
+
action,
|
|
602
|
+
kind: "workflow",
|
|
603
|
+
content: result.output.workflow.content,
|
|
604
|
+
memoryIds,
|
|
605
|
+
resourceRef: pattern.resourceRef,
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
context.mutableMemoryIds,
|
|
609
|
+
);
|
|
590
610
|
}
|
|
591
611
|
await markAiWorkflowPatternReviewed(pattern);
|
|
592
612
|
await aiMemoryLearningRuns.finish({
|
|
@@ -600,6 +620,18 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
600
620
|
summary.updated += changes.filter((change) => change.action === "updated" || change.action === "merged").length;
|
|
601
621
|
if (changes.length === 0) summary.skipped += 1;
|
|
602
622
|
} catch (error) {
|
|
623
|
+
if (error instanceof AiBackgroundCostError || error instanceof AiBackgroundAdmissionError) {
|
|
624
|
+
summary.skipped += 1;
|
|
625
|
+
if (runId)
|
|
626
|
+
await aiMemoryLearningRuns.finish({
|
|
627
|
+
runId,
|
|
628
|
+
status: "skipped",
|
|
629
|
+
durationMs: Date.now() - startedAt,
|
|
630
|
+
changes: [],
|
|
631
|
+
accountedTokens: 0,
|
|
632
|
+
});
|
|
633
|
+
return summary;
|
|
634
|
+
}
|
|
603
635
|
summary.failed += 1;
|
|
604
636
|
if (runId) {
|
|
605
637
|
await aiMemoryLearningRuns
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { editAiDraftText } from "./draft-content";
|
|
2
|
+
import { aiQuotas, AiQuotaError } from "./quotas";
|
|
3
|
+
import { aiChatAccessSubject, isAssistantChatTurn } from "./assistant-models";
|
|
4
|
+
import { aiInputToUserMessage, aiTurnInputToContent } from "./http";
|
|
5
|
+
import { canonicalizeAiConversationAttachments } from "./file-context";
|
|
6
|
+
import { aiResourceMarker } from "./resource-markers";
|
|
7
|
+
import { sql } from "bun";
|
|
8
|
+
import type { AiConversationService, AiDraftContentPart } from "./types";
|
|
9
|
+
import { aiConversations } from "./store";
|
|
10
|
+
import { logger } from "../services/logging";
|
|
11
|
+
import { AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT } from "./files-store";
|
|
12
|
+
|
|
13
|
+
const log = logger("ai:message-queue");
|
|
14
|
+
export const AI_MESSAGE_QUEUE_LIMIT = 32;
|
|
15
|
+
export class AiMessageQueueConflict extends Error {}
|
|
16
|
+
|
|
17
|
+
type Submission = Parameters<AiConversationService["submitChatTurn"]>[0];
|
|
18
|
+
|
|
19
|
+
export const migrateAiMessageQueue = async () => {
|
|
20
|
+
await sql`CREATE TABLE IF NOT EXISTS ai.queued_messages (
|
|
21
|
+
id uuid PRIMARY KEY, conversation_id uuid NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
22
|
+
position bigint GENERATED ALWAYS AS IDENTITY, submission jsonb NOT NULL, content jsonb NOT NULL,
|
|
23
|
+
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','failed','submitted','cancelled')),
|
|
24
|
+
error text, created_at timestamptz NOT NULL DEFAULT now()
|
|
25
|
+
)`.simple();
|
|
26
|
+
await sql`ALTER TABLE ai.queued_messages ADD COLUMN IF NOT EXISTS quota_checked_at TIMESTAMPTZ`.simple();
|
|
27
|
+
await sql`CREATE INDEX IF NOT EXISTS ai_queued_messages_pending ON ai.queued_messages(conversation_id, position) WHERE status IN ('pending','failed')`.simple();
|
|
28
|
+
await sql`CREATE TABLE IF NOT EXISTS ai.queued_message_files (
|
|
29
|
+
message_id uuid NOT NULL REFERENCES ai.queued_messages(id) ON DELETE CASCADE,
|
|
30
|
+
path text NOT NULL, bytes bytea NOT NULL, media_type text NOT NULL, size bigint NOT NULL,
|
|
31
|
+
origin text NOT NULL, dictation_recorded_at timestamptz, updated_at timestamptz NOT NULL, version bigint NOT NULL,
|
|
32
|
+
PRIMARY KEY(message_id, path)
|
|
33
|
+
)`.simple();
|
|
34
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_queued_messages_changed ON ai.queued_messages`.simple();
|
|
35
|
+
await sql`CREATE TRIGGER ai_live_queued_messages_changed AFTER INSERT OR DELETE ON ai.queued_messages
|
|
36
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-detail,conversation-list')`.simple();
|
|
37
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_queued_messages_updated ON ai.queued_messages`.simple();
|
|
38
|
+
await sql`CREATE TRIGGER ai_live_queued_messages_updated AFTER UPDATE ON ai.queued_messages
|
|
39
|
+
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.error IS DISTINCT FROM NEW.error OR OLD.content IS DISTINCT FROM NEW.content)
|
|
40
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-detail,conversation-list')`.simple();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const listQueuedMessages = async (conversationId: string) => {
|
|
44
|
+
const rows = await sql<{ id: string; content: AiDraftContentPart[]; error: string | null }[]>`
|
|
45
|
+
SELECT id, content, error FROM ai.queued_messages
|
|
46
|
+
WHERE conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') ORDER BY position`;
|
|
47
|
+
return rows.map((row) => ({ id: row.id, content: row.content, failed: Boolean(row.error) }));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const queuedMessageAccepted = async (conversationId: string, id: string) => {
|
|
51
|
+
const [row] = await sql`SELECT id FROM ai.queued_messages WHERE id=${id}::uuid AND conversation_id=${conversationId}::uuid`;
|
|
52
|
+
return Boolean(row);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const enqueueChatMessage = async (id: string, input: Submission, content: AiDraftContentPart[]) =>
|
|
56
|
+
sql.begin(async (tx) => {
|
|
57
|
+
const [conversation] = await tx<{ draft_revision: number; project_id: string | null }[]>`
|
|
58
|
+
SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE`;
|
|
59
|
+
if (!conversation) throw new AiMessageQueueConflict("Conversation not found.");
|
|
60
|
+
const [existing] =
|
|
61
|
+
await tx`SELECT id FROM ai.queued_messages WHERE id = ${id}::uuid AND conversation_id = ${input.conversationId}::uuid`;
|
|
62
|
+
if (existing) return;
|
|
63
|
+
if (Number(conversation.draft_revision) !== input.expectedDraftRevision)
|
|
64
|
+
throw new AiMessageQueueConflict("Conversation draft changed before the message was queued.");
|
|
65
|
+
if (conversation.project_id !== input.expectedProjectId)
|
|
66
|
+
throw new AiMessageQueueConflict("Conversation Project changed before the message was queued.");
|
|
67
|
+
const [count] = await tx<
|
|
68
|
+
{ count: number }[]
|
|
69
|
+
>`SELECT count(*)::int AS count FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')`;
|
|
70
|
+
if (count!.count >= AI_MESSAGE_QUEUE_LIMIT) throw new AiMessageQueueConflict("The message queue is full (32 messages).");
|
|
71
|
+
const [size] = await tx<
|
|
72
|
+
{ size: number }[]
|
|
73
|
+
>`SELECT COALESCE(sum(f.size),0)::float8 AS size FROM ai.queued_message_files f JOIN ai.queued_messages q ON q.id = f.message_id WHERE q.conversation_id = ${input.conversationId}::uuid`;
|
|
74
|
+
if (
|
|
75
|
+
Number(size!.size) + (input.runConfig.files?.attached ?? []).reduce((sum, file) => sum + file.size, 0) >
|
|
76
|
+
AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT
|
|
77
|
+
)
|
|
78
|
+
throw new AiMessageQueueConflict("Queued attachments exceed the conversation file budget.");
|
|
79
|
+
const { expectedDraftRevision, ...submission } = input;
|
|
80
|
+
await tx`INSERT INTO ai.queued_messages(id, conversation_id, submission, content) VALUES (${id}::uuid, ${input.conversationId}::uuid, (${JSON.stringify(submission)}::text)::jsonb, (${JSON.stringify(content)}::text)::jsonb)`;
|
|
81
|
+
for (const file of input.runConfig.files?.attached ?? []) {
|
|
82
|
+
const copied =
|
|
83
|
+
await tx`INSERT INTO ai.queued_message_files(message_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
|
|
84
|
+
SELECT ${id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.files
|
|
85
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND path = ${file.path} AND version = ${file.version ?? -1} RETURNING path`;
|
|
86
|
+
if (!copied.length) throw new AiMessageQueueConflict("An attached file changed before the message was queued.");
|
|
87
|
+
}
|
|
88
|
+
await tx`UPDATE ai.conversations SET draft_content = '[]'::jsonb, draft_revision = draft_revision + 1, draft_updated_at = now(), last_used_at = now(), done = CASE WHEN done IS TRUE THEN NULL ELSE done END WHERE id = ${input.conversationId}::uuid`;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** Cancellation and retry share the conversation lock with promotion. */
|
|
92
|
+
export const updateQueuedMessage = async (conversationId: string, id: string, action: "cancel" | "retry") =>
|
|
93
|
+
sql.begin(async (tx) => {
|
|
94
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId}::uuid FOR UPDATE`;
|
|
95
|
+
const changed = await tx`UPDATE ai.queued_messages SET status = ${action === "cancel" ? "cancelled" : "pending"}, error = NULL
|
|
96
|
+
WHERE id = ${id}::uuid AND conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') RETURNING id`;
|
|
97
|
+
if (action === "cancel" && changed.length) {
|
|
98
|
+
await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${id}::uuid`;
|
|
99
|
+
await tx`UPDATE ai.queued_messages SET submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${id}::uuid`;
|
|
100
|
+
}
|
|
101
|
+
return Boolean(changed.length);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** Edit only queued text; immutable attachments and the independent composer draft stay intact. */
|
|
105
|
+
export const editQueuedMessage = async (conversationId: string, id: string, text: string) =>
|
|
106
|
+
sql.begin(async (tx) => {
|
|
107
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${conversationId}::uuid FOR UPDATE`;
|
|
108
|
+
const [row] = await tx<{ submission: Submission; content: AiDraftContentPart[] }[]>`SELECT submission, content FROM ai.queued_messages
|
|
109
|
+
WHERE id = ${id}::uuid AND conversation_id = ${conversationId}::uuid AND status IN ('pending','failed') FOR UPDATE`;
|
|
110
|
+
if (!row) return false;
|
|
111
|
+
const content = editAiDraftText(row.content, text);
|
|
112
|
+
const parts = content.map((part) =>
|
|
113
|
+
part.type === "text"
|
|
114
|
+
? part
|
|
115
|
+
: part.type === "project-file" ? { type: "text" as const, text: `Project file: ${part.path}` }
|
|
116
|
+
: part.type === "file"
|
|
117
|
+
? { type: "attachment" as const, path: part.path, mediaType: part.mediaType, size: part.size }
|
|
118
|
+
: { type: "text" as const, text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }) },
|
|
119
|
+
);
|
|
120
|
+
const { message } = aiInputToUserMessage(aiTurnInputToContent({ content: parts }));
|
|
121
|
+
const userMessage = canonicalizeAiConversationAttachments(message, row.submission.runConfig.files);
|
|
122
|
+
if (userMessage.role !== "user") throw new Error("Queued message must be a user message.");
|
|
123
|
+
const submission = { ...row.submission, userMessage, runConfig: { ...row.submission.runConfig, input: userMessage.content, selectedSkillIds: [...new Set(content.flatMap(part => part.type === "resource" && part.ref.type === "core.ai.skill" ? [part.ref.id] : []))] } };
|
|
124
|
+
await tx`UPDATE ai.queued_messages SET submission = (${JSON.stringify(submission)}::text)::jsonb,
|
|
125
|
+
content = (${JSON.stringify(content)}::text)::jsonb WHERE id = ${id}::uuid`;
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** Existing turn creation owns execution and recovery; the DB lock owns FIFO and deduplication. */
|
|
130
|
+
export const drainQueuedMessages = async (
|
|
131
|
+
enqueue: (job: { conversationId: string; turnId: string }) => Promise<unknown>,
|
|
132
|
+
conversationId?: string,
|
|
133
|
+
) => {
|
|
134
|
+
const rows = await sql<{ id: string; conversation_id: string; submission: Submission }[]>`
|
|
135
|
+
SELECT q.id, q.conversation_id, q.submission FROM ai.queued_messages q
|
|
136
|
+
JOIN ai.conversations c ON c.id = q.conversation_id
|
|
137
|
+
WHERE (q.status = 'pending' OR (q.status = 'failed' AND q.error IN ('quota_exhausted','quota_usage_unknown'))) AND c.archived_at IS NULL AND c.created_by_user_id IS NOT NULL
|
|
138
|
+
AND (${conversationId ?? null}::uuid IS NULL OR q.conversation_id = ${conversationId ?? null}::uuid)
|
|
139
|
+
AND NOT EXISTS (SELECT 1 FROM ai.queued_messages earlier WHERE earlier.conversation_id = q.conversation_id AND earlier.position < q.position AND earlier.status IN ('pending','failed'))
|
|
140
|
+
AND NOT EXISTS (SELECT 1 FROM ai.turns t WHERE t.conversation_id = q.conversation_id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued','running','waiting_for_action'))
|
|
141
|
+
ORDER BY q.quota_checked_at NULLS FIRST, q.position LIMIT ${AI_MESSAGE_QUEUE_LIMIT}`;
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
try {
|
|
144
|
+
const quotaSubject = aiChatAccessSubject(row.submission.runConfig.actor);
|
|
145
|
+
if (isAssistantChatTurn(row.submission.runConfig) && quotaSubject)
|
|
146
|
+
await aiQuotas.assertAllowed(quotaSubject, row.submission.modelProfileId);
|
|
147
|
+
await sql`UPDATE ai.queued_messages SET status='pending', error=NULL WHERE id=${row.id}::uuid AND status='failed' AND error IN ('quota_exhausted','quota_usage_unknown')`;
|
|
148
|
+
const result = await aiConversations.submitChatTurn({ ...row.submission, queuedMessageId: row.id });
|
|
149
|
+
await enqueue({ conversationId: row.conversation_id, turnId: result.turn.id });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof AiQuotaError) {
|
|
152
|
+
await sql`UPDATE ai.queued_messages SET status='failed', error=${error.code}, quota_checked_at=clock_timestamp() WHERE id=${row.id}::uuid AND status IN ('pending','failed')`;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Another dispatcher may have consumed it or a new foreground turn may have won.
|
|
156
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
157
|
+
if (reason === "Queued message is no longer ready." || reason.includes("idx_ai_turns_one_active_per_conversation")) continue;
|
|
158
|
+
await sql`UPDATE ai.queued_messages SET status = 'failed', error = 'dispatch_failed' WHERE id = ${row.id}::uuid AND status = 'pending'`;
|
|
159
|
+
log.error("Queued message dispatch failed", {
|
|
160
|
+
conversationId: row.conversation_id,
|
|
161
|
+
messageId: row.id,
|
|
162
|
+
code: "queue_dispatch_failed",
|
|
163
|
+
errorType: error instanceof Error ? error.name : "unknown",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|