@k2b/cloud 0.6.0 → 0.8.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 +22 -11
- package/scripts/README.md +10 -0
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +20 -35
- package/scripts/preload.ts +16 -12
- package/scripts/sync-recovery-smoke.ts +22 -6
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/build-metadata.ts +8 -1
- package/src/_internal/capabilities.ts +131 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +80 -37
- 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/readiness.ts +4 -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 +18 -151
- package/src/access/PrincipalPicker.tsx +110 -0
- package/src/access/ResourceApiKeys.tsx +1 -1
- package/src/access/ui.ts +1 -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 +201 -0
- package/src/ai/browser.ts +4 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +229 -90
- package/src/ai/chat/builtin-tools.tsx +2 -4
- 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 +83 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +25 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +31 -47
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +21 -2
- package/src/ai/chat/tool-groups.ts +58 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +14 -10
- package/src/ai/chat-tasks.ts +297 -43
- package/src/ai/client/controller.ts +165 -39
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +29 -11
- package/src/ai/client/transport.ts +6 -0
- package/src/ai/code-capability-routes.ts +105 -0
- package/src/ai/code-capability-transport.ts +39 -0
- package/src/ai/code-execution.ts +53 -0
- package/src/ai/code-mode-skill.ts +118 -0
- package/src/ai/code-runtime-tools.ts +179 -0
- package/src/ai/code-source-contracts.ts +270 -0
- package/src/ai/code-source-tools.ts +188 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +99 -3
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +49 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +189 -33
- package/src/ai/fetch-file-tool.ts +2 -2
- package/src/ai/file-content-version.ts +27 -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 +8 -1
- package/src/ai/files-store.ts +212 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +42 -10
- package/src/ai/http.ts +26 -8
- package/src/ai/index.ts +26 -3
- package/src/ai/inference-calls.ts +197 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/live.ts +1 -1
- package/src/ai/markdown-pdf-tool.ts +2 -2
- package/src/ai/memories.ts +30 -27
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +64 -32
- package/src/ai/memory-tool.ts +1 -5
- package/src/ai/memory-workflow-evidence.ts +9 -4
- package/src/ai/message-queue.ts +178 -0
- package/src/ai/migrate.ts +189 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +75 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +18 -43
- package/src/ai/projects.ts +33 -14
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +192 -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 +241 -11
- package/src/ai/run-timeout.ts +12 -0
- package/src/ai/runtime-tools.ts +7 -5
- package/src/ai/runtime.ts +74 -17
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +4 -5
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +58 -0
- package/src/ai/skill-seeds.ts +114 -20
- package/src/ai/skill-tool.ts +21 -5
- package/src/ai/skills-routes.ts +49 -4
- package/src/ai/skills.ts +358 -163
- package/src/ai/store.ts +416 -201
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +12 -1
- package/src/ai/system-prompt.ts +14 -2
- package/src/ai/task-contracts.ts +84 -0
- package/src/ai/task-execution.ts +92 -0
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +32 -0
- package/src/ai/todo-tool.ts +19 -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 +3 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +60 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +80 -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-linux-identities.ts +10 -4
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/app-approval.ts +2 -2
- package/src/api/auth.ts +1 -1
- package/src/api/capabilities.ts +324 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +8 -2
- package/src/api/mcp.ts +45 -34
- 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 +67 -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 +6 -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 +116 -35
- 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 +77 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +10 -1
- package/src/cli/admin/instance.ts +2 -2
- package/src/cli/admin/linux.ts +4 -2
- package/src/cli/admin/sync.ts +1 -1
- package/src/cli/capabilities.ts +42 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/define-env.ts +97 -0
- package/src/config/env.ts +147 -51
- package/src/config/index.ts +3 -1
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +75 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +5 -2
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +5 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +18 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/index.ts +1 -0
- package/src/desktop/solid.tsx +1 -1
- package/src/index.ts +8 -6
- 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/ratelimit.ts +1 -1
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +3 -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/notification-sender.ts +12 -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/app-approval.ts +11 -10
- 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 +8 -5
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/identity/runtime-config.ts +6 -5
- package/src/services/index.ts +15 -0
- package/src/services/ipa/posix.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- 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 +21 -4
- package/src/services/mandates/policy.ts +165 -2
- package/src/services/notifications/batches.ts +125 -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 +131 -72
- package/src/services/pdf/index.ts +11 -8
- 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 +29 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/app.ts +5 -1
- package/src/services/settings/core-settings.ts +26 -35
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/index.ts +3 -3
- package/src/services/settings/store.ts +58 -84
- package/src/services/webauthn.ts +1 -1
- 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 +3 -0
- package/src/shared/markdown/extensions/info-blocks.ts +1 -1
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +30 -16
- package/src/ssr/AppLaunchpad.island.tsx +33 -136
- 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 +22 -23
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MinimalLayout.tsx +1 -1
- 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 +11 -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/index.ts +2 -2
- package/src/workflows/ai/runtime.ts +19 -90
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai/types.ts +6 -82
- 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 +4 -2
- 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/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/server/services/freeipa/test-certificates.ts +0 -44
- package/src/services/notifications/live.ts +0 -50
- package/src/services/session/test-fixture.ts +0 -18
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/migrate.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { migrateAiMessageQueue } from "./message-queue";
|
|
2
3
|
import { migrateAiModelAccess } from "./model-access-migrate";
|
|
4
|
+
import { migrateAiQuotas } from "./quotas-migrate";
|
|
3
5
|
import { withAiShortId } from "./short-id";
|
|
4
|
-
import { migrateAiTurnUsage } from "./usage-migrate";
|
|
5
6
|
|
|
6
7
|
const backfillAiShortIds = async (
|
|
7
8
|
constraint: string,
|
|
@@ -141,10 +142,21 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
141
142
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_failed_at TIMESTAMPTZ`.simple();
|
|
142
143
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_fail_count INTEGER NOT NULL DEFAULT 0`.simple();
|
|
143
144
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ`.simple();
|
|
145
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS done BOOLEAN`.simple();
|
|
146
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ`.simple();
|
|
147
|
+
await sql`DO $$ BEGIN
|
|
148
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'done_at') THEN
|
|
149
|
+
UPDATE ai.conversations SET done = TRUE WHERE done_at IS NOT NULL;
|
|
150
|
+
ALTER TABLE ai.conversations DROP COLUMN done_at;
|
|
151
|
+
END IF;
|
|
152
|
+
END $$`.simple();
|
|
144
153
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS last_viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()`.simple();
|
|
145
154
|
await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET DEFAULT now()`.simple();
|
|
146
155
|
await sql`UPDATE ai.conversations SET last_viewed_at = now() WHERE last_viewed_at IS NULL`.simple();
|
|
147
156
|
await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET NOT NULL`.simple();
|
|
157
|
+
await sql`UPDATE ai.conversations SET last_used_at = GREATEST(created_at, updated_at, last_viewed_at) WHERE last_used_at IS NULL`.simple();
|
|
158
|
+
await sql`ALTER TABLE ai.conversations ALTER COLUMN last_used_at SET DEFAULT now()`.simple();
|
|
159
|
+
await sql`ALTER TABLE ai.conversations ALTER COLUMN last_used_at SET NOT NULL`.simple();
|
|
148
160
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT ''`.simple();
|
|
149
161
|
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_document TSVECTOR NOT NULL DEFAULT ''::tsvector`.simple();
|
|
150
162
|
await sql`
|
|
@@ -242,7 +254,6 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
242
254
|
input_tokens INTEGER CHECK (input_tokens >= 0),
|
|
243
255
|
output_tokens INTEGER CHECK (output_tokens >= 0),
|
|
244
256
|
total_tokens INTEGER CHECK (total_tokens >= 0),
|
|
245
|
-
credits_used DOUBLE PRECISION CHECK (credits_used >= 0),
|
|
246
257
|
mode TEXT,
|
|
247
258
|
repaired BOOLEAN,
|
|
248
259
|
attempts INTEGER CHECK (attempts IS NULL OR attempts >= 1),
|
|
@@ -447,6 +458,7 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
447
458
|
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ`.simple();
|
|
448
459
|
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ`.simple();
|
|
449
460
|
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS run_config JSONB`.simple();
|
|
461
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS run_budget_ms DOUBLE PRECISION`.simple();
|
|
450
462
|
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 0`.simple();
|
|
451
463
|
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_blocks JSONB`.simple();
|
|
452
464
|
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_seq BIGINT NOT NULL DEFAULT 0`.simple();
|
|
@@ -464,9 +476,15 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
464
476
|
`.simple();
|
|
465
477
|
|
|
466
478
|
await sql`
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
479
|
+
DO $$ BEGIN
|
|
480
|
+
IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'ai'
|
|
481
|
+
AND indexname = 'idx_ai_turns_one_active_per_conversation' AND indexdef NOT LIKE '%background%') THEN
|
|
482
|
+
DROP INDEX ai.idx_ai_turns_one_active_per_conversation;
|
|
483
|
+
END IF;
|
|
484
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_one_active_per_conversation
|
|
485
|
+
ON ai.turns(conversation_id)
|
|
486
|
+
WHERE status IN ('queued', 'running', 'waiting_for_action') AND NOT COALESCE(run_config ? 'background', false);
|
|
487
|
+
END $$
|
|
470
488
|
`.simple();
|
|
471
489
|
|
|
472
490
|
await sql`
|
|
@@ -593,6 +611,14 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
593
611
|
)
|
|
594
612
|
`.simple();
|
|
595
613
|
|
|
614
|
+
await sql`CREATE TABLE IF NOT EXISTS ai.turn_generation_timings (
|
|
615
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
616
|
+
request_id TEXT NOT NULL,
|
|
617
|
+
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
618
|
+
ended_at TIMESTAMPTZ,
|
|
619
|
+
PRIMARY KEY (turn_id, request_id)
|
|
620
|
+
)`.simple();
|
|
621
|
+
|
|
596
622
|
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
|
|
597
623
|
|
|
598
624
|
await sql`
|
|
@@ -670,9 +696,6 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
670
696
|
location TEXT NOT NULL DEFAULT 'server',
|
|
671
697
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
672
698
|
approval_state TEXT NOT NULL DEFAULT 'not_required',
|
|
673
|
-
input_meta JSONB,
|
|
674
|
-
output_meta JSONB,
|
|
675
|
-
error TEXT,
|
|
676
699
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
677
700
|
started_at TIMESTAMPTZ,
|
|
678
701
|
approval_requested_at TIMESTAMPTZ,
|
|
@@ -695,6 +718,23 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
695
718
|
`.simple();
|
|
696
719
|
|
|
697
720
|
await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
|
|
721
|
+
|
|
722
|
+
// Capability shape, outcome, and latency belong to capabilities.executions,
|
|
723
|
+
// which the capability dispatcher writes exactly once. ai.tool_calls keeps
|
|
724
|
+
// only AI-specific state: approval, turn linkage, and the correlating
|
|
725
|
+
// request id shared with the execution record and the app's audit rows.
|
|
726
|
+
await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS request_id TEXT`.simple();
|
|
727
|
+
await sql`
|
|
728
|
+
ALTER TABLE ai.tool_calls
|
|
729
|
+
DROP COLUMN IF EXISTS input_meta,
|
|
730
|
+
DROP COLUMN IF EXISTS output_meta,
|
|
731
|
+
DROP COLUMN IF EXISTS error
|
|
732
|
+
`.simple();
|
|
733
|
+
await sql`
|
|
734
|
+
CREATE INDEX IF NOT EXISTS idx_ai_tool_calls_request
|
|
735
|
+
ON ai.tool_calls(request_id)
|
|
736
|
+
WHERE request_id IS NOT NULL
|
|
737
|
+
`.simple();
|
|
698
738
|
await sql`
|
|
699
739
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_idempotency_key
|
|
700
740
|
ON ai.tool_calls(idempotency_key)
|
|
@@ -1391,6 +1431,24 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1391
1431
|
CONSTRAINT ai_skills_extra_frontmatter_check CHECK (jsonb_typeof(extra_frontmatter) = 'object')
|
|
1392
1432
|
)
|
|
1393
1433
|
`.simple();
|
|
1434
|
+
// pg_trgm is a standard PostgreSQL extension, also used by Mail and Grids.
|
|
1435
|
+
await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.simple();
|
|
1436
|
+
await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS search_text TEXT
|
|
1437
|
+
GENERATED ALWAYS AS (name || ' ' || description) STORED`.simple();
|
|
1438
|
+
// Optional BM25 improves relevance without changing which Skills are visible.
|
|
1439
|
+
try {
|
|
1440
|
+
const [extension] = await sql<{ installed: boolean }[]>`
|
|
1441
|
+
SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed
|
|
1442
|
+
`;
|
|
1443
|
+
if (extension?.installed) {
|
|
1444
|
+
await sql
|
|
1445
|
+
.unsafe(`CREATE INDEX IF NOT EXISTS skills_search_bm25_idx
|
|
1446
|
+
ON ai.skills USING bm25 ((search_text)) WITH (text_config='simple')`)
|
|
1447
|
+
.simple();
|
|
1448
|
+
}
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
console.warn("Optional Skill BM25 index unavailable; native Skill search remains active", error);
|
|
1451
|
+
}
|
|
1394
1452
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_short_id ON ai.skills(short_id)`.simple();
|
|
1395
1453
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
|
|
1396
1454
|
await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
|
|
@@ -1401,6 +1459,12 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1401
1459
|
seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
1402
1460
|
)
|
|
1403
1461
|
`.simple();
|
|
1462
|
+
// A seed's UUID deliberately has no FK: it is a durable deletion tombstone.
|
|
1463
|
+
await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id UUID`.simple();
|
|
1464
|
+
await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS catalog_version INTEGER NOT NULL DEFAULT 0`.simple();
|
|
1465
|
+
await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id TEXT,
|
|
1466
|
+
ADD COLUMN IF NOT EXISTS template_version INTEGER, ADD COLUMN IF NOT EXISTS template_hash TEXT`.simple();
|
|
1467
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_template_id ON ai.skills(template_id) WHERE template_id IS NOT NULL`.simple();
|
|
1404
1468
|
await backfillAiShortIds(
|
|
1405
1469
|
"idx_ai_skills_short_id",
|
|
1406
1470
|
await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
|
|
@@ -1558,6 +1622,15 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1558
1622
|
}
|
|
1559
1623
|
await sql`ALTER TABLE ai.project_access ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1560
1624
|
|
|
1625
|
+
// Skills and Projects are shared only with authenticated identities.
|
|
1626
|
+
// Preserve each existing grant and permission level while closing anonymous access.
|
|
1627
|
+
await sql`
|
|
1628
|
+
UPDATE auth.access SET authenticated_only = true
|
|
1629
|
+
WHERE user_id IS NULL AND group_id IS NULL AND service_account_id IS NULL
|
|
1630
|
+
AND authenticated_only = false
|
|
1631
|
+
AND id IN (SELECT access_id FROM ai.skill_access UNION SELECT access_id FROM ai.project_access)
|
|
1632
|
+
`;
|
|
1633
|
+
|
|
1561
1634
|
await sql`
|
|
1562
1635
|
CREATE TABLE IF NOT EXISTS ai.project_knowledge (
|
|
1563
1636
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -1624,6 +1697,15 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1624
1697
|
UNIQUE (project_id, resource_type, resource_id)
|
|
1625
1698
|
)
|
|
1626
1699
|
`.simple();
|
|
1700
|
+
await sql`
|
|
1701
|
+
CREATE TABLE IF NOT EXISTS ai.project_skills (
|
|
1702
|
+
project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
|
|
1703
|
+
skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
|
|
1704
|
+
PRIMARY KEY (project_id, skill_id)
|
|
1705
|
+
)
|
|
1706
|
+
`.simple();
|
|
1707
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_ai_project_skills_skill ON ai.project_skills(skill_id)`.simple();
|
|
1708
|
+
|
|
1627
1709
|
await sql`ALTER TABLE ai.project_resource_refs ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1628
1710
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_resource_refs_short_id ON ai.project_resource_refs(project_id, short_id)`.simple();
|
|
1629
1711
|
await backfillAiShortIds(
|
|
@@ -1661,7 +1743,7 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1661
1743
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1662
1744
|
CONSTRAINT ai_live_invalidation_domains_check CHECK (
|
|
1663
1745
|
cardinality(domains) > 0 AND domains <@ ARRAY[
|
|
1664
|
-
'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
|
|
1746
|
+
'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks', 'conversation-dictations',
|
|
1665
1747
|
'project-list', 'project-detail', 'project-context'
|
|
1666
1748
|
]::text[]
|
|
1667
1749
|
)
|
|
@@ -1918,6 +2000,11 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1918
2000
|
END
|
|
1919
2001
|
$$ LANGUAGE plpgsql
|
|
1920
2002
|
`.simple();
|
|
2003
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_skills_changed ON ai.project_skills`.simple();
|
|
2004
|
+
await sql`
|
|
2005
|
+
CREATE TRIGGER ai_live_project_skills_changed AFTER INSERT OR DELETE ON ai.project_skills
|
|
2006
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
|
|
2007
|
+
`.simple();
|
|
1921
2008
|
await sql`DROP TRIGGER IF EXISTS ai_live_project_knowledge_changed ON ai.project_knowledge`.simple();
|
|
1922
2009
|
await sql`
|
|
1923
2010
|
CREATE TRIGGER ai_live_project_knowledge_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_knowledge
|
|
@@ -2075,8 +2162,99 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
2075
2162
|
ADD COLUMN IF NOT EXISTS trace_id TEXT`.simple();
|
|
2076
2163
|
await sql`CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_user_created ON ai.structured_runs(user_id, created_at DESC)`.simple();
|
|
2077
2164
|
|
|
2078
|
-
await
|
|
2079
|
-
|
|
2165
|
+
await sql`
|
|
2166
|
+
CREATE TABLE IF NOT EXISTS ai.dictations (
|
|
2167
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
2168
|
+
short_id TEXT NOT NULL CONSTRAINT ai_dictations_short_id_key UNIQUE,
|
|
2169
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
2170
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
2171
|
+
operation_id UUID NOT NULL,
|
|
2172
|
+
request_hash TEXT NOT NULL,
|
|
2173
|
+
source_path TEXT NOT NULL,
|
|
2174
|
+
source_bytes BYTEA,
|
|
2175
|
+
media_type TEXT NOT NULL,
|
|
2176
|
+
model_profile_id TEXT NOT NULL,
|
|
2177
|
+
language TEXT,
|
|
2178
|
+
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'canceled')),
|
|
2179
|
+
disposition TEXT NOT NULL DEFAULT 'pending' CHECK (disposition IN ('pending', 'applied', 'discarded')),
|
|
2180
|
+
result TEXT,
|
|
2181
|
+
error_code TEXT,
|
|
2182
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
2183
|
+
lease_token UUID,
|
|
2184
|
+
lease_until TIMESTAMPTZ,
|
|
2185
|
+
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2186
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2187
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2188
|
+
UNIQUE (conversation_id, user_id, operation_id)
|
|
2189
|
+
)
|
|
2190
|
+
`.simple();
|
|
2191
|
+
// Provenance is separate from origin, which still controls file write rights.
|
|
2192
|
+
// Backfill only an unchanged file created in the dictation transaction.
|
|
2193
|
+
await sql`
|
|
2194
|
+
DO $$ BEGIN
|
|
2195
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
2196
|
+
WHERE table_schema = 'ai' AND table_name = 'files' AND column_name = 'dictation_recorded_at') THEN
|
|
2197
|
+
ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS dictation_recorded_at TIMESTAMPTZ;
|
|
2198
|
+
UPDATE ai.files f SET dictation_recorded_at = d.created_at FROM ai.dictations d
|
|
2199
|
+
WHERE f.conversation_id = d.conversation_id AND f.path = d.source_path
|
|
2200
|
+
AND f.origin = 'user' AND f.version = 1 AND f.updated_at = d.created_at
|
|
2201
|
+
AND f.dictation_recorded_at IS NULL;
|
|
2202
|
+
END IF;
|
|
2203
|
+
END $$
|
|
2204
|
+
`.simple();
|
|
2205
|
+
await sql`ALTER TABLE ai.turn_files ADD COLUMN IF NOT EXISTS dictation_recorded_at TIMESTAMPTZ`.simple();
|
|
2206
|
+
|
|
2207
|
+
// Retained for the conversation lifetime so a delayed upload cannot undo a discard.
|
|
2208
|
+
await sql`CREATE TABLE IF NOT EXISTS ai.dictation_cancellations (
|
|
2209
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
2210
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
2211
|
+
operation_id UUID NOT NULL,
|
|
2212
|
+
PRIMARY KEY (conversation_id, user_id, operation_id)
|
|
2213
|
+
)`.simple();
|
|
2214
|
+
await sql`CREATE INDEX IF NOT EXISTS ai_dictations_pending ON ai.dictations(conversation_id, created_at, id) WHERE disposition = 'pending'`.simple();
|
|
2215
|
+
await sql`CREATE INDEX IF NOT EXISTS ai_dictations_recovery ON ai.dictations(status, next_attempt_at, lease_until) WHERE status IN ('queued', 'running')`.simple();
|
|
2216
|
+
await sql.begin(async (tx) => {
|
|
2217
|
+
await tx`ALTER TABLE ai.live_invalidation_outbox DROP CONSTRAINT IF EXISTS ai_live_invalidation_domains_check`;
|
|
2218
|
+
await tx`ALTER TABLE ai.live_invalidation_outbox ADD CONSTRAINT ai_live_invalidation_domains_check CHECK (
|
|
2219
|
+
domains <@ ARRAY['conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
|
|
2220
|
+
'conversation-dictations', 'project-list', 'project-detail', 'project-context']::text[] AND cardinality(domains) > 0
|
|
2221
|
+
)`;
|
|
2222
|
+
});
|
|
2223
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_dictations_changed ON ai.dictations`.simple();
|
|
2224
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_dictations_state_changed ON ai.dictations`.simple();
|
|
2225
|
+
await sql`CREATE TRIGGER ai_live_dictations_changed AFTER INSERT OR DELETE ON ai.dictations
|
|
2226
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-dictations')`.simple();
|
|
2227
|
+
await sql`CREATE TRIGGER ai_live_dictations_state_changed AFTER UPDATE ON ai.dictations
|
|
2228
|
+
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.disposition IS DISTINCT FROM NEW.disposition)
|
|
2229
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-dictations')`.simple();
|
|
2230
|
+
|
|
2231
|
+
// A tool artifact may be reused only by its producing call and while unedited.
|
|
2232
|
+
await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS producer_call_key TEXT`.simple();
|
|
2080
2233
|
|
|
2234
|
+
await migrateAiModelAccess();
|
|
2235
|
+
await migrateAiQuotas();
|
|
2236
|
+
|
|
2237
|
+
await sql`CREATE OR REPLACE FUNCTION ai.sidebar_tool_label(blocks jsonb) RETURNS jsonb AS $$
|
|
2238
|
+
SELECT jsonb_build_object('label', left(COALESCE(block->'presentation'->>'title', block->>'name'),200), 'status', block->>'status')
|
|
2239
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(blocks) = 'array' THEN blocks ELSE '[]'::jsonb END) WITH ORDINALITY AS entries(block, n)
|
|
2240
|
+
WHERE block->>'kind' = 'tool' ORDER BY n DESC LIMIT 1
|
|
2241
|
+
$$ LANGUAGE sql IMMUTABLE`.simple();
|
|
2242
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_sidebar_tool_changed ON ai.turns`.simple();
|
|
2243
|
+
await sql`CREATE TRIGGER ai_live_sidebar_tool_changed AFTER UPDATE OF live_blocks ON ai.turns
|
|
2244
|
+
FOR EACH ROW WHEN (ai.sidebar_tool_label(OLD.live_blocks) IS DISTINCT FROM ai.sidebar_tool_label(NEW.live_blocks))
|
|
2245
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')`.simple();
|
|
2246
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_sidebar_plan_changed ON ai.messages`.simple();
|
|
2247
|
+
await sql`CREATE TRIGGER ai_live_sidebar_plan_changed AFTER INSERT ON ai.messages
|
|
2248
|
+
FOR EACH ROW WHEN (NEW.role = 'tool_result' OR NEW.meta IS NOT NULL)
|
|
2249
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')`.simple();
|
|
2250
|
+
// Separate run history keeps background tool loops out of the interactive chat.
|
|
2251
|
+
await sql`CREATE TABLE IF NOT EXISTS ai.task_messages (LIKE ai.messages INCLUDING DEFAULTS INCLUDING CONSTRAINTS, FOREIGN KEY (conversation_id) REFERENCES ai.conversations(id) ON DELETE CASCADE)`.simple();
|
|
2252
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS ai_task_messages_id ON ai.task_messages(id)`.simple();
|
|
2253
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS ai_task_messages_seq ON ai.task_messages(loop_id, seq)`.simple();
|
|
2254
|
+
|
|
2255
|
+
await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS result_text TEXT`.simple();
|
|
2256
|
+
await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ`.simple();
|
|
2257
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS background_received_at TIMESTAMPTZ`.simple();
|
|
2258
|
+
await migrateAiMessageQueue();
|
|
2081
2259
|
console.log(" ✓ ai conversation tables");
|
|
2082
2260
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { coreSettings } from "../services";
|
|
3
|
+
import { audit } from "../services/audit";
|
|
4
|
+
import { invalidateSettingsCache, set as setSetting } from "../services/settings";
|
|
5
|
+
import { decryptValue } from "../services/settings/crypto";
|
|
6
|
+
import { type AiModelPricing, AiModelPricingSchema } from "../shared/ai-costs";
|
|
7
|
+
import { AiQuotaError } from "./quotas";
|
|
8
|
+
import { parseAiModelProfiles } from "./settings";
|
|
9
|
+
|
|
10
|
+
/** Change prices only, preserving credentials and model access in their own stores. */
|
|
11
|
+
export async function setAiModelPricing(id: string, pricing: AiModelPricing | null, expected: AiModelPricing | null, actorId: string) {
|
|
12
|
+
if (pricing) AiModelPricingSchema.parse(pricing);
|
|
13
|
+
const key = "ai.model_profiles_json";
|
|
14
|
+
const fallback = await coreSettings.get<string>(key);
|
|
15
|
+
const result = await sql.begin(async (db) => {
|
|
16
|
+
const [row] = await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key} FOR UPDATE`;
|
|
17
|
+
const raw = row ? await decryptValue(row.value) : fallback;
|
|
18
|
+
if (typeof raw !== "string") throw new Error("Invalid model configuration.");
|
|
19
|
+
const parsed = parseAiModelProfiles(raw);
|
|
20
|
+
if (parsed.error) throw new Error(parsed.error.message);
|
|
21
|
+
const profile = parsed.profiles.find((profile) => profile.id === id);
|
|
22
|
+
if (!profile) throw new AiQuotaError("quota_conflict", "Model no longer exists.");
|
|
23
|
+
if (profile.capabilities.includes("transcription")) throw new AiQuotaError("quota_conflict", "Audio pricing is not supported.");
|
|
24
|
+
if (JSON.stringify(profile.pricing ?? null) !== JSON.stringify(expected))
|
|
25
|
+
throw new AiQuotaError("quota_conflict", "Model prices changed. Read them again before updating.");
|
|
26
|
+
if (pricing) profile.pricing = pricing;
|
|
27
|
+
else delete profile.pricing;
|
|
28
|
+
await setSetting(key, JSON.stringify(parsed.profiles), db);
|
|
29
|
+
await audit.record(
|
|
30
|
+
{
|
|
31
|
+
action: "ai.model.pricing",
|
|
32
|
+
outcome: "allowed",
|
|
33
|
+
actor: { userId: actorId },
|
|
34
|
+
target: { type: "ai-model", id },
|
|
35
|
+
metadata: { pricing },
|
|
36
|
+
},
|
|
37
|
+
db,
|
|
38
|
+
);
|
|
39
|
+
return { id, pricing: profile.pricing ?? null };
|
|
40
|
+
});
|
|
41
|
+
await invalidateSettingsCache([key]);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Standalone subprocess. No server imports or application credentials.
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
6
|
+
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const inputSchema = z.object({
|
|
11
|
+
pdf: z.string().max(Math.ceil((MAX_BYTES * 4) / 3)),
|
|
12
|
+
pages: z.array(z.number().int().positive()).min(1).max(3),
|
|
13
|
+
});
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const root = dirname(require.resolve("pdfjs-dist/package.json"));
|
|
16
|
+
try {
|
|
17
|
+
const input = inputSchema.parse(JSON.parse(await Bun.stdin.text()));
|
|
18
|
+
const data = Buffer.from(input.pdf, "base64");
|
|
19
|
+
if (data.byteLength > MAX_BYTES) throw new Error("PDF exceeds the 10 MiB limit.");
|
|
20
|
+
const task = getDocument({
|
|
21
|
+
data: new Uint8Array(data),
|
|
22
|
+
verbosity: 0,
|
|
23
|
+
cMapUrl: join(root, "cmaps/"),
|
|
24
|
+
cMapPacked: true,
|
|
25
|
+
standardFontDataUrl: join(root, "standard_fonts/"),
|
|
26
|
+
wasmUrl: join(root, "wasm/"),
|
|
27
|
+
useSystemFonts: false,
|
|
28
|
+
stopAtErrors: true,
|
|
29
|
+
// Bound decoded source-image allocations as well as the output canvas.
|
|
30
|
+
maxImageSize: 16 * 1024 * 1024,
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const document = await task.promise;
|
|
34
|
+
if (input.pages.some((page) => page > document.numPages))
|
|
35
|
+
throw new Error(`PDF has ${document.numPages} pages. Choose pages within that range.`);
|
|
36
|
+
const pages: { page: number; png: string }[] = [];
|
|
37
|
+
let outputBytes = 0;
|
|
38
|
+
for (const number of input.pages) {
|
|
39
|
+
const page = await document.getPage(number);
|
|
40
|
+
const original = page.getViewport({ scale: 1 });
|
|
41
|
+
if (!(original.width > 0 && original.height > 0 && Number.isFinite(original.width + original.height)))
|
|
42
|
+
throw new Error("PDF page dimensions are invalid.");
|
|
43
|
+
const viewport = page.getViewport({ scale: Math.min(2, 2000 / Math.max(original.width, original.height)) });
|
|
44
|
+
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
45
|
+
try {
|
|
46
|
+
// PDF.js uses the Canvas API implemented by the native canvas; its types
|
|
47
|
+
// describe browser HTMLCanvasElement rather than the supported Node canvas.
|
|
48
|
+
await page.render({ canvas: canvas as unknown as HTMLCanvasElement, viewport }).promise;
|
|
49
|
+
const png: Uint8Array = canvas.toBuffer("image/png");
|
|
50
|
+
outputBytes += png.byteLength;
|
|
51
|
+
if (outputBytes > MAX_BYTES) throw new Error("Rendered PDF exceeds the 10 MiB image budget.");
|
|
52
|
+
pages.push({ page: number, png: Buffer.from(png).toString("base64") });
|
|
53
|
+
} finally {
|
|
54
|
+
canvas.width = 0;
|
|
55
|
+
canvas.height = 0;
|
|
56
|
+
page.cleanup();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write(JSON.stringify({ totalPages: document.numPages, pages }));
|
|
60
|
+
} finally {
|
|
61
|
+
await task.destroy();
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const name = error instanceof Error ? error.name : "";
|
|
65
|
+
const message =
|
|
66
|
+
name === "PasswordException"
|
|
67
|
+
? "Password-protected PDFs are not supported. Provide an unlocked copy."
|
|
68
|
+
: name === "InvalidPDFException"
|
|
69
|
+
? "The PDF is invalid or damaged."
|
|
70
|
+
: error instanceof Error
|
|
71
|
+
? error.message.slice(0, 500)
|
|
72
|
+
: "PDF rendering failed.";
|
|
73
|
+
process.stdout.write(JSON.stringify({ error: message }));
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Small, deterministic PDF with embedded vector text; no fixture download. */
|
|
2
|
+
export function visionPdfFixture(): Uint8Array {
|
|
3
|
+
const objects = [
|
|
4
|
+
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
5
|
+
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
|
|
6
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R >>",
|
|
7
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R >>",
|
|
8
|
+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
9
|
+
...["Invoice INV-7", "Total 42.00 EUR"].map((text) => {
|
|
10
|
+
const content = `BT /F1 20 Tf 20 120 Td (${text}) Tj ET`;
|
|
11
|
+
return `<< /Length ${content.length} >>\nstream\n${content}\nendstream`;
|
|
12
|
+
}),
|
|
13
|
+
];
|
|
14
|
+
let pdf = "%PDF-1.4\n";
|
|
15
|
+
const offsets = [0];
|
|
16
|
+
for (const [index, object] of objects.entries()) {
|
|
17
|
+
offsets.push(pdf.length);
|
|
18
|
+
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
|
19
|
+
}
|
|
20
|
+
const start = pdf.length;
|
|
21
|
+
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
|
|
22
|
+
pdf += offsets
|
|
23
|
+
.slice(1)
|
|
24
|
+
.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`)
|
|
25
|
+
.join("");
|
|
26
|
+
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${start}\n%%EOF`;
|
|
27
|
+
return new TextEncoder().encode(pdf);
|
|
28
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
declare const __CLOUD_PDF_RENDER_WORKER__: string | undefined;
|
|
5
|
+
let activeRenders = 0;
|
|
6
|
+
export const PDF_MAX_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
export const PdfPages = z
|
|
8
|
+
.array(z.number().int().positive())
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(3)
|
|
11
|
+
.refine((pages) => new Set(pages).size === pages.length, "Choose each page only once.");
|
|
12
|
+
const RenderResult = z.object({
|
|
13
|
+
totalPages: z.number().int().positive(),
|
|
14
|
+
pages: z
|
|
15
|
+
.array(z.object({ page: z.number().int().positive(), png: z.string() }))
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(3),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Isolate native decoding from the server: abort/deadline kills the process. */
|
|
21
|
+
export async function renderPdfPages(bytes: Uint8Array, pages: number[] = [1], signal?: AbortSignal) {
|
|
22
|
+
PdfPages.parse(pages);
|
|
23
|
+
if (bytes.byteLength > PDF_MAX_BYTES) throw new Error("PDF exceeds the 10 MiB view_image limit.");
|
|
24
|
+
if (process.platform !== "linux") throw new Error("PDF page inspection requires the Linux Cloud runtime with memory isolation.");
|
|
25
|
+
const boundedSignal = AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]);
|
|
26
|
+
boundedSignal.throwIfAborted();
|
|
27
|
+
if (activeRenders >= 2) throw new Error("PDF rendering is busy. Retry after the current inspection finishes.");
|
|
28
|
+
activeRenders++;
|
|
29
|
+
try {
|
|
30
|
+
const worker =
|
|
31
|
+
typeof __CLOUD_PDF_RENDER_WORKER__ === "string"
|
|
32
|
+
? new URL(__CLOUD_PDF_RENDER_WORKER__, import.meta.url)
|
|
33
|
+
: new URL("./pdf-render-worker.ts", import.meta.url);
|
|
34
|
+
// RLIMIT_DATA bounds the decoder heap/native writable mappings on Linux.
|
|
35
|
+
// Positional arguments keep paths out of shell code. No elevated privileges.
|
|
36
|
+
const child = Bun.spawn(
|
|
37
|
+
["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', "pdf-decoder", process.execPath, "--no-env-file", fileURLToPath(worker)],
|
|
38
|
+
{
|
|
39
|
+
stdin: new Blob([JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages })]),
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "ignore",
|
|
42
|
+
// The decoder needs no application credentials or runtime configuration.
|
|
43
|
+
env: { PATH: process.env.PATH, LANG: "C.UTF-8" },
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
const stop = () => child.kill("SIGKILL");
|
|
47
|
+
boundedSignal.addEventListener("abort", stop, { once: true });
|
|
48
|
+
if (boundedSignal.aborted) stop();
|
|
49
|
+
try {
|
|
50
|
+
let length = 0;
|
|
51
|
+
const stdout = child.stdout.pipeThrough(
|
|
52
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
53
|
+
transform(chunk, controller) {
|
|
54
|
+
length += chunk.byteLength;
|
|
55
|
+
// Base64 overhead plus a small JSON envelope over the aggregate PNG budget.
|
|
56
|
+
if (length > Math.ceil((PDF_MAX_BYTES * 4) / 3) + 4096) throw new Error("Rendered PDF exceeds the 10 MiB image budget.");
|
|
57
|
+
controller.enqueue(chunk);
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
const text = await new Response(stdout).text();
|
|
62
|
+
boundedSignal.throwIfAborted();
|
|
63
|
+
if (!text.trim()) throw new Error("PDF decoder failed or exceeded its memory budget.");
|
|
64
|
+
const output: unknown = JSON.parse(text);
|
|
65
|
+
const failure = z.object({ error: z.string().max(500) }).safeParse(output);
|
|
66
|
+
if (failure.success) throw new Error(failure.data.error);
|
|
67
|
+
if ((await child.exited) !== 0) throw new Error("PDF rendering failed.");
|
|
68
|
+
return RenderResult.parse(output);
|
|
69
|
+
} finally {
|
|
70
|
+
boundedSignal.removeEventListener("abort", stop);
|
|
71
|
+
stop();
|
|
72
|
+
await child.exited;
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
activeRenders--;
|
|
76
|
+
}
|
|
77
|
+
}
|