@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/migrate.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { migrateAiQuotas } from "./quotas-migrate";
|
|
2
|
+
import { migrateAiMessageQueue } from "./message-queue";
|
|
1
3
|
import { sql } from "bun";
|
|
2
4
|
import { migrateAiModelAccess } from "./model-access-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,22 @@ 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.unsafe(`CREATE INDEX IF NOT EXISTS skills_search_bm25_idx
|
|
1445
|
+
ON ai.skills USING bm25 ((search_text)) WITH (text_config='simple')`).simple();
|
|
1446
|
+
}
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
console.warn("Optional Skill BM25 index unavailable; native Skill search remains active", error);
|
|
1449
|
+
}
|
|
1394
1450
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_short_id ON ai.skills(short_id)`.simple();
|
|
1395
1451
|
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
|
|
1396
1452
|
await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
|
|
@@ -1401,6 +1457,12 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1401
1457
|
seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
1402
1458
|
)
|
|
1403
1459
|
`.simple();
|
|
1460
|
+
// A seed's UUID deliberately has no FK: it is a durable deletion tombstone.
|
|
1461
|
+
await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id UUID`.simple();
|
|
1462
|
+
await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS catalog_version INTEGER NOT NULL DEFAULT 0`.simple();
|
|
1463
|
+
await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id TEXT,
|
|
1464
|
+
ADD COLUMN IF NOT EXISTS template_version INTEGER, ADD COLUMN IF NOT EXISTS template_hash TEXT`.simple();
|
|
1465
|
+
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
1466
|
await backfillAiShortIds(
|
|
1405
1467
|
"idx_ai_skills_short_id",
|
|
1406
1468
|
await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
|
|
@@ -1558,6 +1620,15 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1558
1620
|
}
|
|
1559
1621
|
await sql`ALTER TABLE ai.project_access ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1560
1622
|
|
|
1623
|
+
// Skills and Projects are shared only with authenticated identities.
|
|
1624
|
+
// Preserve each existing grant and permission level while closing anonymous access.
|
|
1625
|
+
await sql`
|
|
1626
|
+
UPDATE auth.access SET authenticated_only = true
|
|
1627
|
+
WHERE user_id IS NULL AND group_id IS NULL AND service_account_id IS NULL
|
|
1628
|
+
AND authenticated_only = false
|
|
1629
|
+
AND id IN (SELECT access_id FROM ai.skill_access UNION SELECT access_id FROM ai.project_access)
|
|
1630
|
+
`;
|
|
1631
|
+
|
|
1561
1632
|
await sql`
|
|
1562
1633
|
CREATE TABLE IF NOT EXISTS ai.project_knowledge (
|
|
1563
1634
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -1624,6 +1695,15 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1624
1695
|
UNIQUE (project_id, resource_type, resource_id)
|
|
1625
1696
|
)
|
|
1626
1697
|
`.simple();
|
|
1698
|
+
await sql`
|
|
1699
|
+
CREATE TABLE IF NOT EXISTS ai.project_skills (
|
|
1700
|
+
project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
|
|
1701
|
+
skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
|
|
1702
|
+
PRIMARY KEY (project_id, skill_id)
|
|
1703
|
+
)
|
|
1704
|
+
`.simple();
|
|
1705
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_ai_project_skills_skill ON ai.project_skills(skill_id)`.simple();
|
|
1706
|
+
|
|
1627
1707
|
await sql`ALTER TABLE ai.project_resource_refs ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1628
1708
|
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
1709
|
await backfillAiShortIds(
|
|
@@ -1661,7 +1741,7 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1661
1741
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1662
1742
|
CONSTRAINT ai_live_invalidation_domains_check CHECK (
|
|
1663
1743
|
cardinality(domains) > 0 AND domains <@ ARRAY[
|
|
1664
|
-
'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
|
|
1744
|
+
'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks', 'conversation-dictations',
|
|
1665
1745
|
'project-list', 'project-detail', 'project-context'
|
|
1666
1746
|
]::text[]
|
|
1667
1747
|
)
|
|
@@ -1918,6 +1998,11 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1918
1998
|
END
|
|
1919
1999
|
$$ LANGUAGE plpgsql
|
|
1920
2000
|
`.simple();
|
|
2001
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_skills_changed ON ai.project_skills`.simple();
|
|
2002
|
+
await sql`
|
|
2003
|
+
CREATE TRIGGER ai_live_project_skills_changed AFTER INSERT OR DELETE ON ai.project_skills
|
|
2004
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
|
|
2005
|
+
`.simple();
|
|
1921
2006
|
await sql`DROP TRIGGER IF EXISTS ai_live_project_knowledge_changed ON ai.project_knowledge`.simple();
|
|
1922
2007
|
await sql`
|
|
1923
2008
|
CREATE TRIGGER ai_live_project_knowledge_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_knowledge
|
|
@@ -2075,8 +2160,99 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
2075
2160
|
ADD COLUMN IF NOT EXISTS trace_id TEXT`.simple();
|
|
2076
2161
|
await sql`CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_user_created ON ai.structured_runs(user_id, created_at DESC)`.simple();
|
|
2077
2162
|
|
|
2078
|
-
await
|
|
2079
|
-
|
|
2163
|
+
await sql`
|
|
2164
|
+
CREATE TABLE IF NOT EXISTS ai.dictations (
|
|
2165
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
2166
|
+
short_id TEXT NOT NULL CONSTRAINT ai_dictations_short_id_key UNIQUE,
|
|
2167
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
2168
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
2169
|
+
operation_id UUID NOT NULL,
|
|
2170
|
+
request_hash TEXT NOT NULL,
|
|
2171
|
+
source_path TEXT NOT NULL,
|
|
2172
|
+
source_bytes BYTEA,
|
|
2173
|
+
media_type TEXT NOT NULL,
|
|
2174
|
+
model_profile_id TEXT NOT NULL,
|
|
2175
|
+
language TEXT,
|
|
2176
|
+
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'canceled')),
|
|
2177
|
+
disposition TEXT NOT NULL DEFAULT 'pending' CHECK (disposition IN ('pending', 'applied', 'discarded')),
|
|
2178
|
+
result TEXT,
|
|
2179
|
+
error_code TEXT,
|
|
2180
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
2181
|
+
lease_token UUID,
|
|
2182
|
+
lease_until TIMESTAMPTZ,
|
|
2183
|
+
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2184
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2185
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2186
|
+
UNIQUE (conversation_id, user_id, operation_id)
|
|
2187
|
+
)
|
|
2188
|
+
`.simple();
|
|
2189
|
+
// Provenance is separate from origin, which still controls file write rights.
|
|
2190
|
+
// Backfill only an unchanged file created in the dictation transaction.
|
|
2191
|
+
await sql`
|
|
2192
|
+
DO $$ BEGIN
|
|
2193
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
2194
|
+
WHERE table_schema = 'ai' AND table_name = 'files' AND column_name = 'dictation_recorded_at') THEN
|
|
2195
|
+
ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS dictation_recorded_at TIMESTAMPTZ;
|
|
2196
|
+
UPDATE ai.files f SET dictation_recorded_at = d.created_at FROM ai.dictations d
|
|
2197
|
+
WHERE f.conversation_id = d.conversation_id AND f.path = d.source_path
|
|
2198
|
+
AND f.origin = 'user' AND f.version = 1 AND f.updated_at = d.created_at
|
|
2199
|
+
AND f.dictation_recorded_at IS NULL;
|
|
2200
|
+
END IF;
|
|
2201
|
+
END $$
|
|
2202
|
+
`.simple();
|
|
2203
|
+
await sql`ALTER TABLE ai.turn_files ADD COLUMN IF NOT EXISTS dictation_recorded_at TIMESTAMPTZ`.simple();
|
|
2204
|
+
|
|
2205
|
+
// Retained for the conversation lifetime so a delayed upload cannot undo a discard.
|
|
2206
|
+
await sql`CREATE TABLE IF NOT EXISTS ai.dictation_cancellations (
|
|
2207
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
2208
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
2209
|
+
operation_id UUID NOT NULL,
|
|
2210
|
+
PRIMARY KEY (conversation_id, user_id, operation_id)
|
|
2211
|
+
)`.simple();
|
|
2212
|
+
await sql`CREATE INDEX IF NOT EXISTS ai_dictations_pending ON ai.dictations(conversation_id, created_at, id) WHERE disposition = 'pending'`.simple();
|
|
2213
|
+
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();
|
|
2214
|
+
await sql.begin(async (tx) => {
|
|
2215
|
+
await tx`ALTER TABLE ai.live_invalidation_outbox DROP CONSTRAINT IF EXISTS ai_live_invalidation_domains_check`;
|
|
2216
|
+
await tx`ALTER TABLE ai.live_invalidation_outbox ADD CONSTRAINT ai_live_invalidation_domains_check CHECK (
|
|
2217
|
+
domains <@ ARRAY['conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
|
|
2218
|
+
'conversation-dictations', 'project-list', 'project-detail', 'project-context']::text[] AND cardinality(domains) > 0
|
|
2219
|
+
)`;
|
|
2220
|
+
});
|
|
2221
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_dictations_changed ON ai.dictations`.simple();
|
|
2222
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_dictations_state_changed ON ai.dictations`.simple();
|
|
2223
|
+
await sql`CREATE TRIGGER ai_live_dictations_changed AFTER INSERT OR DELETE ON ai.dictations
|
|
2224
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-dictations')`.simple();
|
|
2225
|
+
await sql`CREATE TRIGGER ai_live_dictations_state_changed AFTER UPDATE ON ai.dictations
|
|
2226
|
+
FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.disposition IS DISTINCT FROM NEW.disposition)
|
|
2227
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-dictations')`.simple();
|
|
2228
|
+
|
|
2229
|
+
// A tool artifact may be reused only by its producing call and while unedited.
|
|
2230
|
+
await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS producer_call_key TEXT`.simple();
|
|
2080
2231
|
|
|
2232
|
+
await migrateAiModelAccess();
|
|
2233
|
+
await migrateAiQuotas();
|
|
2234
|
+
|
|
2235
|
+
await sql`CREATE OR REPLACE FUNCTION ai.sidebar_tool_label(blocks jsonb) RETURNS jsonb AS $$
|
|
2236
|
+
SELECT jsonb_build_object('label', left(COALESCE(block->'presentation'->>'title', block->>'name'),200), 'status', block->>'status')
|
|
2237
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(blocks) = 'array' THEN blocks ELSE '[]'::jsonb END) WITH ORDINALITY AS entries(block, n)
|
|
2238
|
+
WHERE block->>'kind' = 'tool' ORDER BY n DESC LIMIT 1
|
|
2239
|
+
$$ LANGUAGE sql IMMUTABLE`.simple();
|
|
2240
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_sidebar_tool_changed ON ai.turns`.simple();
|
|
2241
|
+
await sql`CREATE TRIGGER ai_live_sidebar_tool_changed AFTER UPDATE OF live_blocks ON ai.turns
|
|
2242
|
+
FOR EACH ROW WHEN (ai.sidebar_tool_label(OLD.live_blocks) IS DISTINCT FROM ai.sidebar_tool_label(NEW.live_blocks))
|
|
2243
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')`.simple();
|
|
2244
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_sidebar_plan_changed ON ai.messages`.simple();
|
|
2245
|
+
await sql`CREATE TRIGGER ai_live_sidebar_plan_changed AFTER INSERT ON ai.messages
|
|
2246
|
+
FOR EACH ROW WHEN (NEW.role = 'tool_result' OR NEW.meta IS NOT NULL)
|
|
2247
|
+
EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')`.simple();
|
|
2248
|
+
// Separate run history keeps background tool loops out of the interactive chat.
|
|
2249
|
+
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();
|
|
2250
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS ai_task_messages_id ON ai.task_messages(id)`.simple();
|
|
2251
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS ai_task_messages_seq ON ai.task_messages(loop_id, seq)`.simple();
|
|
2252
|
+
|
|
2253
|
+
await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS result_text TEXT`.simple();
|
|
2254
|
+
await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ`.simple();
|
|
2255
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS background_received_at TIMESTAMPTZ`.simple();
|
|
2256
|
+
await migrateAiMessageQueue();
|
|
2081
2257
|
console.log(" ✓ ai conversation tables");
|
|
2082
2258
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { coreSettings } from "../services";
|
|
3
|
+
import { decryptValue } from "../services/settings/crypto";
|
|
4
|
+
import { invalidateSettingsCache, set as setSetting } from "../services/settings";
|
|
5
|
+
import { audit } from "../services/audit";
|
|
6
|
+
import { AiModelPricingSchema, type AiModelPricing } from "../shared/ai-costs";
|
|
7
|
+
import { parseAiModelProfiles } from "./settings";
|
|
8
|
+
import { AiQuotaError } from "./quotas";
|
|
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,74 @@
|
|
|
1
|
+
// Standalone subprocess. No server imports or application credentials.
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
6
|
+
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
const inputSchema = z.object({
|
|
10
|
+
pdf: z.string().max(Math.ceil((MAX_BYTES * 4) / 3)),
|
|
11
|
+
pages: z.array(z.number().int().positive()).min(1).max(3),
|
|
12
|
+
});
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const root = dirname(require.resolve("pdfjs-dist/package.json"));
|
|
15
|
+
try {
|
|
16
|
+
const input = inputSchema.parse(JSON.parse(await Bun.stdin.text()));
|
|
17
|
+
const data = Buffer.from(input.pdf, "base64");
|
|
18
|
+
if (data.byteLength > MAX_BYTES) throw new Error("PDF exceeds the 10 MiB limit.");
|
|
19
|
+
const task = getDocument({
|
|
20
|
+
data: new Uint8Array(data),
|
|
21
|
+
verbosity: 0,
|
|
22
|
+
cMapUrl: join(root, "cmaps/"),
|
|
23
|
+
cMapPacked: true,
|
|
24
|
+
standardFontDataUrl: join(root, "standard_fonts/"),
|
|
25
|
+
wasmUrl: join(root, "wasm/"),
|
|
26
|
+
useSystemFonts: false,
|
|
27
|
+
stopAtErrors: true,
|
|
28
|
+
// Bound decoded source-image allocations as well as the output canvas.
|
|
29
|
+
maxImageSize: 16 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
const document = await task.promise;
|
|
33
|
+
if (input.pages.some((page) => page > document.numPages))
|
|
34
|
+
throw new Error(`PDF has ${document.numPages} pages. Choose pages within that range.`);
|
|
35
|
+
const pages: { page: number; png: string }[] = [];
|
|
36
|
+
let outputBytes = 0;
|
|
37
|
+
for (const number of input.pages) {
|
|
38
|
+
const page = await document.getPage(number);
|
|
39
|
+
const original = page.getViewport({ scale: 1 });
|
|
40
|
+
if (!(original.width > 0 && original.height > 0 && Number.isFinite(original.width + original.height)))
|
|
41
|
+
throw new Error("PDF page dimensions are invalid.");
|
|
42
|
+
const viewport = page.getViewport({ scale: Math.min(2, 2000 / Math.max(original.width, original.height)) });
|
|
43
|
+
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
44
|
+
try {
|
|
45
|
+
// PDF.js uses the Canvas API implemented by the native canvas; its types
|
|
46
|
+
// describe browser HTMLCanvasElement rather than the supported Node canvas.
|
|
47
|
+
await page.render({ canvas: canvas as unknown as HTMLCanvasElement, viewport }).promise;
|
|
48
|
+
const png: Uint8Array = canvas.toBuffer("image/png");
|
|
49
|
+
outputBytes += png.byteLength;
|
|
50
|
+
if (outputBytes > MAX_BYTES) throw new Error("Rendered PDF exceeds the 10 MiB image budget.");
|
|
51
|
+
pages.push({ page: number, png: Buffer.from(png).toString("base64") });
|
|
52
|
+
} finally {
|
|
53
|
+
canvas.width = 0;
|
|
54
|
+
canvas.height = 0;
|
|
55
|
+
page.cleanup();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
process.stdout.write(JSON.stringify({ totalPages: document.numPages, pages }));
|
|
59
|
+
} finally {
|
|
60
|
+
await task.destroy();
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const name = error instanceof Error ? error.name : "";
|
|
64
|
+
const message =
|
|
65
|
+
name === "PasswordException"
|
|
66
|
+
? "Password-protected PDFs are not supported. Provide an unlocked copy."
|
|
67
|
+
: name === "InvalidPDFException"
|
|
68
|
+
? "The PDF is invalid or damaged."
|
|
69
|
+
: error instanceof Error
|
|
70
|
+
? error.message.slice(0, 500)
|
|
71
|
+
: "PDF rendering failed.";
|
|
72
|
+
process.stdout.write(JSON.stringify({ error: message }));
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Hono, type MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getCapability } from "../_internal/registry";
|
|
4
4
|
import { CloudResourceRefSchema, cloudResourceRefAppId, resolveCapabilityResourceReader } from "../contracts/capabilities";
|
|
5
|
-
import {
|
|
5
|
+
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
6
6
|
import { type AuthContext, auth, err, fail, ok, rateLimit, respond, v } from "../server";
|
|
7
7
|
import { decodeAiFileContent } from "./files-store";
|
|
8
8
|
import {
|
|
@@ -28,7 +28,7 @@ const ProjectFieldsSchema = z.object({
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const UpdateProjectSchema = ProjectFieldsSchema.partial().refine((input) => Object.keys(input).length > 0, "No changes supplied.");
|
|
31
|
-
const ProjectAccessSchema = z.object({ principal:
|
|
31
|
+
const ProjectAccessSchema = z.object({ principal: AuthenticatedPrincipalSchema, permission: z.enum(["read", "write", "admin"]) });
|
|
32
32
|
const ProjectAccessUpdateSchema = z.object({ permission: z.enum(["read", "write", "admin"]) });
|
|
33
33
|
const KnowledgeSchema = z.object({
|
|
34
34
|
title: z.string().trim().min(1).max(200),
|
|
@@ -70,7 +70,7 @@ type AiProjectsRouteDependencies = {
|
|
|
70
70
|
const buildAiProjectsRoutes = (dependencies: AiProjectsRouteDependencies = {}) =>
|
|
71
71
|
new Hono<AuthContext>()
|
|
72
72
|
.use(dependencies.limit ?? rateLimit())
|
|
73
|
-
.use("*", dependencies.authenticate ?? auth.requireRole("
|
|
73
|
+
.use("*", dependencies.authenticate ?? auth.requireRole("authenticated"))
|
|
74
74
|
.get("/", async (c) =>
|
|
75
75
|
respond(c, ok({ projects: (await aiProjects.list(c.get("accessSubject") ?? null)).map(publicProject) })),
|
|
76
76
|
)
|
package/src/ai/projects.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
2
|
+
import { aiFileContentVersion, AiFileWriteError, AiFileVersionConflict } from "./file-content-version";
|
|
1
3
|
import { type SQL, type SQLQuery, sql } from "bun";
|
|
2
4
|
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
3
5
|
import type { AccessSubject } from "../server";
|
|
@@ -168,7 +170,7 @@ const principalForSubject = (subject: AccessSubject): Principal =>
|
|
|
168
170
|
: { type: "service_account", serviceAccountId: subject.serviceAccountId };
|
|
169
171
|
|
|
170
172
|
const accessMatch = (subject: AccessSubject | null): SQLQuery =>
|
|
171
|
-
buildAccessPrincipalCondition({
|
|
173
|
+
subject === null ? sql`FALSE` : buildAccessPrincipalCondition({
|
|
172
174
|
subject,
|
|
173
175
|
columns: {
|
|
174
176
|
userId: sql`access.user_id`,
|
|
@@ -299,6 +301,7 @@ const createProjectAccess = async (
|
|
|
299
301
|
input: { principal: Principal; permission: AiProjectPermission },
|
|
300
302
|
db: SQL,
|
|
301
303
|
): Promise<AiProjectAccess> => {
|
|
304
|
+
AuthenticatedPrincipalSchema.parse(input.principal);
|
|
302
305
|
const created = await createAccess(input, db);
|
|
303
306
|
if (!created.ok) throw new Error(created.error.message);
|
|
304
307
|
let shortId = "";
|
|
@@ -439,7 +442,6 @@ export const aiProjects = {
|
|
|
439
442
|
JOIN auth.access access ON access.id = project_access.access_id
|
|
440
443
|
WHERE access.permission <> 'none' AND ${match}
|
|
441
444
|
ORDER BY lower(project.name), project.id
|
|
442
|
-
LIMIT 200
|
|
443
445
|
`;
|
|
444
446
|
return (await Promise.all(rows.map((row) => toProject(row, subject)))).filter((project): project is AiProject => Boolean(project));
|
|
445
447
|
},
|
|
@@ -787,24 +789,33 @@ export const aiProjects = {
|
|
|
787
789
|
});
|
|
788
790
|
},
|
|
789
791
|
|
|
790
|
-
async listFiles(projectId: string, subject: AccessSubject | null): Promise<AiProjectFile[]> {
|
|
792
|
+
async listFiles(projectId: string, subject: AccessSubject | null, page?: { after?: string; limit: number }): Promise<AiProjectFile[]> {
|
|
791
793
|
if (!(await requireProject(projectId, subject, "read"))) return [];
|
|
794
|
+
if (page && (!Number.isInteger(page.limit) || page.limit < 1 || page.limit > 1000)) throw new Error("Invalid file page limit");
|
|
792
795
|
return (
|
|
793
796
|
await sql<
|
|
794
797
|
FileRow[]
|
|
795
|
-
>`SELECT id, short_id, project_id, path, media_type, size, updated_at FROM ai.project_files WHERE project_id = ${projectId}::uuid ORDER BY path LIMIT 500`
|
|
798
|
+
>`SELECT id, short_id, project_id, path, media_type, size, updated_at FROM ai.project_files WHERE project_id = ${projectId}::uuid AND path > ${page?.after ?? ""} ORDER BY path LIMIT ${page?.limit ?? 500}`
|
|
796
799
|
).map(toFile);
|
|
797
800
|
},
|
|
798
801
|
|
|
799
802
|
async writeFile(
|
|
800
803
|
projectId: string,
|
|
801
804
|
subject: AccessSubject | null,
|
|
802
|
-
input: { path: string; mediaType: string; bytes: Uint8Array },
|
|
805
|
+
input: { path: string; mediaType: string; bytes: Uint8Array; expectedVersion?: string | null },
|
|
803
806
|
): Promise<AiProjectFile | null> {
|
|
804
807
|
if (!(await requireProject(projectId, subject, "write"))) return null;
|
|
805
|
-
if (input.bytes.byteLength > AI_PROJECT_FILE_MAX_BYTES) throw new
|
|
808
|
+
if (input.bytes.byteLength > AI_PROJECT_FILE_MAX_BYTES) throw new AiFileWriteError("STORAGE_FULL", "Project file exceeds the size limit; nothing was written.");
|
|
806
809
|
const path = normalizeProjectPath(input.path);
|
|
807
810
|
return sql.begin(async (tx) => {
|
|
811
|
+
const [row] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id=${projectId}::uuid FOR UPDATE`;
|
|
812
|
+
const project = row ? await toProject(row, subject, tx) : null;
|
|
813
|
+
if (!project || !hasPermission(project.permission, "write")) return null;
|
|
814
|
+
if (input.expectedVersion !== undefined) {
|
|
815
|
+
const [existing] = await tx<(FileRow & { bytes: Uint8Array })[]>`SELECT id,short_id,project_id,path,media_type,size,updated_at,bytes FROM ai.project_files WHERE project_id=${projectId}::uuid AND path=${path}`;
|
|
816
|
+
const version = existing ? aiFileContentVersion({ ...toFile(existing), bytes: existing.bytes }) : null;
|
|
817
|
+
if (version !== input.expectedVersion) throw new AiFileVersionConflict();
|
|
818
|
+
}
|
|
808
819
|
const rows = await withAiShortIdForDb(
|
|
809
820
|
tx,
|
|
810
821
|
"idx_ai_project_files_short_id",
|
|
@@ -851,6 +862,9 @@ export const aiProjects = {
|
|
|
851
862
|
async deleteFile(projectId: string, fileId: string, subject: AccessSubject | null): Promise<boolean> {
|
|
852
863
|
if (!(await requireProject(projectId, subject, "write"))) return false;
|
|
853
864
|
return sql.begin(async (tx) => {
|
|
865
|
+
const [row] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id=${projectId}::uuid FOR UPDATE`;
|
|
866
|
+
const project = row ? await toProject(row, subject, tx) : null;
|
|
867
|
+
if (!project || !hasPermission(project.permission, "write")) return false;
|
|
854
868
|
const rows = await tx<{ id: string }[]>`
|
|
855
869
|
DELETE FROM ai.project_files WHERE short_id = ${fileId} AND project_id = ${projectId}::uuid RETURNING id
|
|
856
870
|
`;
|
package/src/ai/protocol.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type AiTurnBlock =
|
|
|
33
33
|
name: string;
|
|
34
34
|
args?: unknown;
|
|
35
35
|
status: AiToolBlockStatus;
|
|
36
|
+
/** Localized transient host status, without tool payload data. */
|
|
37
|
+
progress?: string;
|
|
36
38
|
result?: unknown;
|
|
37
39
|
isError?: boolean;
|
|
38
40
|
/** Present while status is awaiting_approval. */
|
|
@@ -62,6 +64,7 @@ export type AiWireEvent =
|
|
|
62
64
|
/** Authoritative ordered baseline for this attempt. Optional for older senders. */
|
|
63
65
|
blocks?: AiTurnBlock[];
|
|
64
66
|
})
|
|
67
|
+
| (AiWireEventBase & { type: "message_saved"; message: AiStoredMessage })
|
|
65
68
|
| (AiWireEventBase & { type: "block_set"; block: AiTurnBlock })
|
|
66
69
|
| (AiWireEventBase & { type: "block_delta"; blockId: string; blockKind: "text" | "thinking"; delta: string })
|
|
67
70
|
| (AiWireEventBase & {
|