@k2b/cloud 0.6.0-rc.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 +98 -0
- package/package.json +128 -0
- package/public/logo.svg +1 -0
- package/scripts/app-favicon.test.ts +49 -0
- package/scripts/app-favicon.ts +51 -0
- package/scripts/build.ts +205 -0
- package/scripts/preload.ts +164 -0
- package/scripts/sync-recovery-smoke.ts +200 -0
- package/src/_internal/app-favicon.ts +2 -0
- package/src/_internal/app-presentation.ts +61 -0
- package/src/_internal/app-workspace-state.ts +24 -0
- package/src/_internal/bounded-json.ts +47 -0
- package/src/_internal/build-metadata.ts +9 -0
- package/src/_internal/capabilities.ts +1118 -0
- package/src/_internal/define-app.ts +801 -0
- package/src/_internal/heartbeat.ts +167 -0
- package/src/_internal/help-catalog.ts +241 -0
- package/src/_internal/help.ts +95 -0
- package/src/_internal/nats-connection.ts +34 -0
- package/src/_internal/page-responses.ts +40 -0
- package/src/_internal/process-sync.ts +117 -0
- package/src/_internal/readiness.ts +3 -0
- package/src/_internal/registry-validation.ts +136 -0
- package/src/_internal/registry-watch.ts +52 -0
- package/src/_internal/registry.ts +338 -0
- package/src/_internal/runtime-compatibility.ts +64 -0
- package/src/_internal/runtime-context.ts +84 -0
- package/src/_internal/runtime-watcher.ts +78 -0
- package/src/_internal/static-assets.ts +86 -0
- package/src/_internal/status-preserving-ssr.ts +49 -0
- package/src/access/PermissionEditor.tsx +486 -0
- package/src/access/ResourceApiKeys.tsx +271 -0
- package/src/access/messages.ts +121 -0
- package/src/access/ui.ts +16 -0
- package/src/account/Avatar.tsx +28 -0
- package/src/account/EntitySearch.tsx +363 -0
- package/src/account/avatar-upload-dialog.tsx +197 -0
- package/src/account/avatar-upload.ts +149 -0
- package/src/account/ui.ts +3 -0
- package/src/ai/admin.ts +47 -0
- package/src/ai/approval-routes.ts +130 -0
- package/src/ai/approvals.ts +139 -0
- package/src/ai/assistant-models.ts +61 -0
- package/src/ai/attachments.ts +40 -0
- package/src/ai/browser.ts +92 -0
- package/src/ai/capabilities.ts +714 -0
- package/src/ai/capability-execution.ts +151 -0
- package/src/ai/chat/blocks.render.test.tsx +1108 -0
- package/src/ai/chat/blocks.tsx +730 -0
- package/src/ai/chat/builtin-tools.tsx +428 -0
- package/src/ai/chat/capability-table.tsx +87 -0
- package/src/ai/chat/composer-adapter.ts +169 -0
- package/src/ai/chat/file-tools.tsx +81 -0
- package/src/ai/chat/message-actions.tsx +370 -0
- package/src/ai/chat/message-utils.ts +479 -0
- package/src/ai/chat/messages.ts +43 -0
- package/src/ai/chat/presentation.tsx +395 -0
- package/src/ai/chat/primitives.tsx +3 -0
- package/src/ai/chat/tool-disclosure.tsx +41 -0
- package/src/ai/chat/turn-navigator-utils.ts +28 -0
- package/src/ai/chat/turn-navigator.tsx +164 -0
- package/src/ai/chat/user-message.tsx +149 -0
- package/src/ai/chat/visual-tools.tsx +435 -0
- package/src/ai/chat/web-tools.tsx +202 -0
- package/src/ai/chat-task-contracts.ts +113 -0
- package/src/ai/chat-tasks.ts +952 -0
- package/src/ai/client/controller.ts +1211 -0
- package/src/ai/client/file-source.ts +95 -0
- package/src/ai/client/live-connection.ts +140 -0
- package/src/ai/client/projection.ts +178 -0
- package/src/ai/client/transport.ts +111 -0
- package/src/ai/compaction.ts +182 -0
- package/src/ai/credentials.ts +124 -0
- package/src/ai/default-tool-contracts.ts +108 -0
- package/src/ai/default-tools.ts +127 -0
- package/src/ai/enrich.ts +280 -0
- package/src/ai/executor.ts +1624 -0
- package/src/ai/fetch-file-tool.ts +313 -0
- package/src/ai/file-context.ts +92 -0
- package/src/ai/file-media-type.ts +28 -0
- package/src/ai/file-mount.ts +16 -0
- package/src/ai/file-tools.ts +566 -0
- package/src/ai/files-store.ts +390 -0
- package/src/ai/firecrawl-tools.ts +305 -0
- package/src/ai/grids-skill.ts +126 -0
- package/src/ai/http.ts +265 -0
- package/src/ai/index.ts +337 -0
- package/src/ai/kit-skill.ts +22 -0
- package/src/ai/limits.ts +5 -0
- package/src/ai/live-events.ts +289 -0
- package/src/ai/live-messages.ts +45 -0
- package/src/ai/live-outbox.ts +102 -0
- package/src/ai/live-routes.ts +412 -0
- package/src/ai/live.ts +2 -0
- package/src/ai/maintenance.ts +302 -0
- package/src/ai/markdown-pdf-tool.ts +95 -0
- package/src/ai/memories.ts +605 -0
- package/src/ai/memory-learning-runs.ts +222 -0
- package/src/ai/memory-learning.ts +619 -0
- package/src/ai/memory-tool.ts +120 -0
- package/src/ai/memory-workflow-evidence.ts +158 -0
- package/src/ai/migrate.ts +2082 -0
- package/src/ai/model-access-migrate.ts +30 -0
- package/src/ai/model-access.ts +191 -0
- package/src/ai/personal-agent.ts +6 -0
- package/src/ai/prefs.ts +72 -0
- package/src/ai/project-tool.ts +94 -0
- package/src/ai/projects-routes.ts +277 -0
- package/src/ai/projects.ts +902 -0
- package/src/ai/protocol.ts +279 -0
- package/src/ai/provider.ts +73 -0
- package/src/ai/public-projection.ts +25 -0
- package/src/ai/resource-markers.ts +31 -0
- package/src/ai/resource-refs.ts +70 -0
- package/src/ai/routes.ts +1131 -0
- package/src/ai/runtime-tools.ts +39 -0
- package/src/ai/runtime.ts +544 -0
- package/src/ai/settings.ts +529 -0
- package/src/ai/short-id.ts +45 -0
- package/src/ai/skill-catalog.ts +73 -0
- package/src/ai/skill-format.ts +145 -0
- package/src/ai/skill-seeds.ts +430 -0
- package/src/ai/skill-tool.ts +81 -0
- package/src/ai/skills-routes.ts +143 -0
- package/src/ai/skills.ts +934 -0
- package/src/ai/solid.ts +27 -0
- package/src/ai/store.ts +3113 -0
- package/src/ai/stream.ts +308 -0
- package/src/ai/structured-runs.ts +66 -0
- package/src/ai/structured.ts +186 -0
- package/src/ai/system-prompt.ts +170 -0
- package/src/ai/task-prompt.ts +22 -0
- package/src/ai/timeline.ts +148 -0
- package/src/ai/tool-audit.ts +177 -0
- package/src/ai/tool-result-budget.ts +16 -0
- package/src/ai/tools.ts +155 -0
- package/src/ai/types.ts +967 -0
- package/src/ai/ui.tsx +39 -0
- package/src/ai/usage-migrate.ts +81 -0
- package/src/ai/usage.ts +315 -0
- package/src/ai/validate.ts +54 -0
- package/src/ai/vision-tool.ts +79 -0
- package/src/api/accounts-entities.ts +174 -0
- package/src/api/admin-ai-projects.ts +74 -0
- package/src/api/admin-ai-skills.ts +76 -0
- package/src/api/admin-ai-usage.ts +42 -0
- package/src/api/admin-core-settings.ts +508 -0
- package/src/api/admin-identity.ts +148 -0
- package/src/api/admin-lifecycle.ts +206 -0
- package/src/api/admin-linux-identities.ts +66 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/app-approval.ts +129 -0
- package/src/api/apps.ts +51 -0
- package/src/api/auth/schemas.ts +54 -0
- package/src/api/auth.ts +370 -0
- package/src/api/capabilities.ts +658 -0
- package/src/api/help-messages.ts +19 -0
- package/src/api/help.ts +78 -0
- package/src/api/index.ts +108 -0
- package/src/api/mcp.ts +744 -0
- package/src/api/me-rail.ts +45 -0
- package/src/api/me.ts +753 -0
- package/src/api/search/schemas.ts +79 -0
- package/src/api/search.bench.ts +185 -0
- package/src/api/search.ts +407 -0
- package/src/api/sync-ops.ts +79 -0
- package/src/api/widgets.ts +143 -0
- package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
- package/src/browser/CloudResourceSearch.tsx +791 -0
- package/src/browser/app-approval-vault.ts +305 -0
- package/src/browser/app-approval.ts +234 -0
- package/src/browser/live-websocket.ts +268 -0
- package/src/browser/locale-preference.ts +25 -0
- package/src/browser/notification-target.ts +27 -0
- package/src/browser/notification-timeout.ts +14 -0
- package/src/browser/notifications.ts +181 -0
- package/src/browser/resource-clipboard.ts +70 -0
- package/src/browser/resource-picker.tsx +39 -0
- package/src/browser/resource-search-messages.ts +55 -0
- package/src/browser/resource-search.ts +27 -0
- package/src/capabilities/client.ts +149 -0
- package/src/capabilities/response.ts +39 -0
- package/src/capabilities/server.ts +215 -0
- package/src/capabilities/signals.ts +4 -0
- package/src/capabilities/testing.ts +12 -0
- package/src/capabilities/types.ts +32 -0
- package/src/cli/access.ts +499 -0
- package/src/cli/account.ts +382 -0
- package/src/cli/admin/account-administration.ts +45 -0
- package/src/cli/admin/account-categories.ts +49 -0
- package/src/cli/admin/ai-usage.ts +118 -0
- package/src/cli/admin/app-sign-in.ts +49 -0
- package/src/cli/admin/data.ts +343 -0
- package/src/cli/admin/documentation.ts +34 -0
- package/src/cli/admin/gateway.ts +155 -0
- package/src/cli/admin/index.ts +61 -0
- package/src/cli/admin/instance.ts +250 -0
- package/src/cli/admin/jobs.ts +223 -0
- package/src/cli/admin/legal.ts +122 -0
- package/src/cli/admin/linux.ts +53 -0
- package/src/cli/admin/logs.ts +324 -0
- package/src/cli/admin/metrics.ts +142 -0
- package/src/cli/admin/nats.ts +167 -0
- package/src/cli/admin/notifications.ts +516 -0
- package/src/cli/admin/shared.ts +113 -0
- package/src/cli/admin/sync.ts +188 -0
- package/src/cli/admin/telemetry.ts +314 -0
- package/src/cli/admin/webhooks.ts +222 -0
- package/src/cli/admin/workflows.ts +367 -0
- package/src/cli/apps.ts +40 -0
- package/src/cli/capabilities.ts +239 -0
- package/src/cli/commands.ts +604 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/locale.ts +31 -0
- package/src/cli/output.ts +58 -0
- package/src/clients/core.ts +20 -0
- package/src/config/env.ts +54 -0
- package/src/config/index.ts +6 -0
- package/src/contracts/account-categories.ts +62 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app-approval.ts +197 -0
- package/src/contracts/app.ts +162 -0
- package/src/contracts/capabilities.ts +484 -0
- package/src/contracts/index.ts +14 -0
- package/src/contracts/notification-live.ts +66 -0
- package/src/contracts/notification-types.ts +235 -0
- package/src/contracts/posix.ts +69 -0
- package/src/contracts/profile.ts +85 -0
- package/src/contracts/rail-preferences.ts +53 -0
- package/src/contracts/registry.ts +110 -0
- package/src/contracts/resource-clipboard.ts +33 -0
- package/src/contracts/settings-types.ts +98 -0
- package/src/contracts/shared.ts +408 -0
- package/src/contracts/user-notifications.ts +101 -0
- package/src/contracts/widgets.ts +183 -0
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/index.ts +58 -0
- package/src/server/actor.ts +41 -0
- package/src/server/api/index.ts +2 -0
- package/src/server/api/respond.ts +96 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/help.ts +187 -0
- package/src/server/index.ts +89 -0
- package/src/server/locale.ts +55 -0
- package/src/server/middleware/auth.ts +386 -0
- package/src/server/middleware/index.ts +28 -0
- package/src/server/middleware/invocation.ts +55 -0
- package/src/server/middleware/middleware.ts +65 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +125 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/route-template.ts +46 -0
- package/src/server/middleware/runtime.ts +22 -0
- package/src/server/middleware/settings.ts +55 -0
- package/src/server/middleware/validator.ts +49 -0
- package/src/server/middleware/workload.ts +22 -0
- package/src/server/ratelimit.ts +151 -0
- package/src/server/request-cookies.ts +18 -0
- package/src/server/services/access.ts +644 -0
- package/src/server/services/freeipa/client.ts +137 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +123 -0
- package/src/server/services/freeipa/test-certificates.ts +44 -0
- package/src/server/services/freeipa/tls.ts +44 -0
- package/src/server/services/freeipa/transport.ts +116 -0
- package/src/server/services/freeipa/util.ts +59 -0
- package/src/server/services/geo.ts +198 -0
- package/src/server/services/index.ts +48 -0
- package/src/server/services/pagination.ts +22 -0
- package/src/server/services/services.ts +13 -0
- package/src/server/time.ts +33 -0
- package/src/services/account-category-policy.ts +51 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +940 -0
- package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
- package/src/services/account-lifecycle/notification-sender.ts +13 -0
- package/src/services/account-lifecycle/scheduler.ts +506 -0
- package/src/services/account-model.ts +22 -0
- package/src/services/accounts/app.ts +1671 -0
- package/src/services/accounts/authz.ts +44 -0
- package/src/services/accounts/avatar.ts +120 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +46 -0
- package/src/services/accounts/duplicate-emails.ts +82 -0
- package/src/services/accounts/entities.ts +622 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +272 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +66 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +499 -0
- package/src/services/accounts/model.ts +129 -0
- package/src/services/accounts/notification-sender.ts +31 -0
- package/src/services/accounts/posix.ts +386 -0
- package/src/services/accounts/request-policy.ts +11 -0
- package/src/services/accounts/switching.ts +126 -0
- package/src/services/accounts/users.ts +745 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/app-approval.ts +498 -0
- package/src/services/audit/index.ts +438 -0
- package/src/services/auth-flows/index.ts +15 -0
- package/src/services/auth-flows/ipa.ts +197 -0
- package/src/services/auth-flows/magic-link.ts +185 -0
- package/src/services/auth-flows/notification-sender.ts +10 -0
- package/src/services/auth-flows/password-reset.ts +268 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/document-extraction.ts +148 -0
- package/src/services/freeipa-config.ts +179 -0
- package/src/services/gateway.ts +186 -0
- package/src/services/identity/constants.ts +18 -0
- package/src/services/identity/index.ts +52 -0
- package/src/services/identity/invocation-actor.ts +120 -0
- package/src/services/identity/invocation-authority.ts +53 -0
- package/src/services/identity/invocation-operations.ts +15 -0
- package/src/services/identity/invocation-token.ts +276 -0
- package/src/services/identity/key-config.ts +41 -0
- package/src/services/identity/key-ring.ts +530 -0
- package/src/services/identity/metrics.ts +29 -0
- package/src/services/identity/routes.ts +18 -0
- package/src/services/identity/runtime-config.ts +88 -0
- package/src/services/identity/session-token.ts +152 -0
- package/src/services/identity/signer-refresh.ts +44 -0
- package/src/services/identity/workload-auth.ts +64 -0
- package/src/services/index.ts +187 -0
- package/src/services/ipa/auth.ts +216 -0
- package/src/services/ipa/connection.ts +57 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/groups.ts +722 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/posix.ts +19 -0
- package/src/services/ipa/profile.ts +132 -0
- package/src/services/ipa/runtime.ts +28 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.ts +100 -0
- package/src/services/ipa/sync.ts +862 -0
- package/src/services/ipa/users.ts +806 -0
- package/src/services/legal-consent.ts +76 -0
- package/src/services/logging/index.ts +477 -0
- package/src/services/logging/redaction.ts +20 -0
- package/src/services/logging/trace.ts +1231 -0
- package/src/services/mandates/index.ts +855 -0
- package/src/services/mandates/policy.ts +85 -0
- package/src/services/network-security.ts +31 -0
- package/src/services/notifications/batches.ts +722 -0
- package/src/services/notifications/browser.ts +252 -0
- package/src/services/notifications/catalog.ts +113 -0
- package/src/services/notifications/channels.ts +104 -0
- package/src/services/notifications/dispatcher.ts +139 -0
- package/src/services/notifications/email.ts +118 -0
- package/src/services/notifications/index.ts +633 -0
- package/src/services/notifications/live.ts +50 -0
- package/src/services/notifications/observability.ts +407 -0
- package/src/services/notifications/platform.ts +421 -0
- package/src/services/notifications/runtime.ts +120 -0
- package/src/services/notifications/user.ts +248 -0
- package/src/services/notifications/web-push-transport.ts +135 -0
- package/src/services/oauth-tokens.ts +209 -0
- package/src/services/outbox.ts +187 -0
- package/src/services/pdf/gotenberg.ts +290 -0
- package/src/services/pdf/index.ts +43 -0
- package/src/services/pdf/markdown.ts +209 -0
- package/src/services/pdf/template-preview.ts +122 -0
- package/src/services/postgres.ts +76 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +51 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +263 -0
- package/src/services/rail-preferences.ts +27 -0
- package/src/services/runtime-lifecycle.ts +191 -0
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.ts +891 -0
- package/src/services/service-accounts.ts +184 -0
- package/src/services/session/index.ts +236 -0
- package/src/services/session/test-fixture.ts +18 -0
- package/src/services/session/user.ts +144 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +86 -0
- package/src/services/settings/core-settings.ts +796 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +374 -0
- package/src/services/settings/index.ts +215 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +241 -0
- package/src/services/settings/templates.ts +4 -0
- package/src/services/sync-ops.ts +403 -0
- package/src/services/topic-cursor.ts +22 -0
- package/src/services/weather/forecast.ts +323 -0
- package/src/services/weather/geo.ts +119 -0
- package/src/services/weather/index.ts +67 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +133 -0
- package/src/services/weather/migrate.ts +45 -0
- package/src/services/weather/settings.ts +66 -0
- package/src/services/weather/short-id.ts +57 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/services/webauthn.ts +385 -0
- package/src/shared/account-action-notice.ts +70 -0
- package/src/shared/account-display.ts +15 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/ai-platform-prompt.ts +134 -0
- package/src/shared/ai-usage.ts +49 -0
- package/src/shared/app-presentation.ts +48 -0
- package/src/shared/app-url.ts +12 -0
- package/src/shared/branding.ts +14 -0
- package/src/shared/capability-messages.ts +70 -0
- package/src/shared/email-html.ts +54 -0
- package/src/shared/format.ts +91 -0
- package/src/shared/help.ts +68 -0
- package/src/shared/icons.ts +400 -0
- package/src/shared/index.ts +54 -0
- package/src/shared/locale.ts +48 -0
- package/src/shared/login-method.ts +44 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +105 -0
- package/src/shared/markdown/extensions/guided-help.ts +100 -0
- package/src/shared/markdown/extensions/images.ts +79 -0
- package/src/shared/markdown/extensions/info-blocks.ts +97 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +43 -0
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +109 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +193 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/network-address.ts +106 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/template-rendering.ts +140 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +53 -0
- package/src/ssr/AdminSidebar.tsx +50 -0
- package/src/ssr/AppLaunchpad.island.tsx +236 -0
- package/src/ssr/AppWorkspaceController.island.tsx +21 -0
- package/src/ssr/BrowserNotifications.island.tsx +135 -0
- package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
- package/src/ssr/GlobalSearchDialog.tsx +35 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
- package/src/ssr/Layout.render.test.tsx +148 -0
- package/src/ssr/Layout.tsx +292 -0
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutFooter.tsx +22 -0
- package/src/ssr/LayoutHeader.tsx +81 -0
- package/src/ssr/LayoutHelp.tsx +869 -0
- package/src/ssr/LayoutPreferences.island.tsx +36 -0
- package/src/ssr/LayoutRail.tsx +58 -0
- package/src/ssr/MinimalLayout.tsx +46 -0
- package/src/ssr/PageError.tsx +29 -0
- package/src/ssr/ProfilePreferences.island.tsx +92 -0
- package/src/ssr/RailApps.island.tsx +61 -0
- package/src/ssr/RailEditor.tsx +293 -0
- package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/admin-active-link.ts +47 -0
- package/src/ssr/admin-navigation.ts +78 -0
- package/src/ssr/app-appearance.ts +23 -0
- package/src/ssr/app-navigation.ts +41 -0
- package/src/ssr/help-messages.ts +104 -0
- package/src/ssr/index.ts +14 -0
- package/src/ssr/islands/SearchBar.island.tsx +80 -0
- package/src/ssr/islands/index.ts +16 -0
- package/src/ssr/layout-context.ts +17 -0
- package/src/ssr/layout-help-markdown.ts +9 -0
- package/src/ssr/layout-help-navigation.ts +24 -0
- package/src/ssr/layout-help-url.ts +5 -0
- package/src/ssr/layout-runtime.ts +22 -0
- package/src/ssr/page-error-messages.ts +25 -0
- package/src/ssr/platform-messages.ts +135 -0
- package/src/ssr/preference-controller.ts +44 -0
- package/src/ssr/profile-preferences-messages.ts +29 -0
- package/src/ssr/rail-context.ts +21 -0
- package/src/ssr/rail-messages.ts +66 -0
- package/src/ssr/rail-navigation.ts +60 -0
- package/src/ssr/runtime.ts +31 -0
- package/src/ssr/url-filter.ts +127 -0
- package/src/styles/base-popover.css +36 -0
- package/src/styles/effects.css +851 -0
- package/src/styles/global.css +268 -0
- package/src/styles/input.css +58 -0
- package/src/styles/tokens.css +230 -0
- package/src/styles/utilities-buttons.css +405 -0
- package/src/styles/utilities-data.css +33 -0
- package/src/styles/utilities-feedback.css +232 -0
- package/src/styles/utilities-layout.css +603 -0
- package/src/styles/utilities-markdown-editor.css +525 -0
- package/src/styles/utilities-markdown-table.css +234 -0
- package/src/styles/utilities-navigation.css +1054 -0
- package/src/types/ambient.d.ts +24 -0
- package/src/workflows/ai/index.ts +17 -0
- package/src/workflows/ai/runtime.ts +286 -0
- package/src/workflows/ai/store.ts +314 -0
- package/src/workflows/ai/types.ts +111 -0
- package/src/workflows/ai-actions.ts +269 -0
- package/src/workflows/authoring.ts +88 -0
- package/src/workflows/builtins.ts +193 -0
- package/src/workflows/contracts.ts +280 -0
- package/src/workflows/definition.ts +334 -0
- package/src/workflows/editor.ts +113 -0
- package/src/workflows/index.ts +71 -0
- package/src/workflows/language/binder.ts +45 -0
- package/src/workflows/language/canonical.ts +29 -0
- package/src/workflows/language/compiler.ts +396 -0
- package/src/workflows/language/expressions.ts +34 -0
- package/src/workflows/language/index.ts +20 -0
- package/src/workflows/language/references.ts +55 -0
- package/src/workflows/language/schema.ts +134 -0
- package/src/workflows/language/strict-yaml.ts +131 -0
- package/src/workflows/manifest.ts +24 -0
- package/src/workflows/module.ts +12 -0
- package/src/workflows/runtime/coordinator.ts +115 -0
- package/src/workflows/runtime/executor.ts +1003 -0
- package/src/workflows/runtime/index.ts +60 -0
- package/src/workflows/runtime/instant.ts +8 -0
- package/src/workflows/runtime/ports.ts +213 -0
- package/src/workflows/runtime/schedule.ts +203 -0
- package/src/workflows/runtime/trigger.ts +37 -0
- package/src/workflows/runtime/wake.ts +13 -0
- package/src/workflows/store/actions.ts +438 -0
- package/src/workflows/store/budget.ts +145 -0
- package/src/workflows/store/definitions.ts +345 -0
- package/src/workflows/store/events.ts +383 -0
- package/src/workflows/store/index.ts +107 -0
- package/src/workflows/store/observability.ts +594 -0
- package/src/workflows/store/runs.ts +975 -0
- package/src/workflows/store/transaction.ts +23 -0
- package/src/workflows/store/worker.ts +439 -0
- package/src/workflows/testing/conformance.ts +72 -0
- package/src/workflows/testing/index.ts +11 -0
- package/src/workflows/testing/process-fixtures.ts +275 -0
|
@@ -0,0 +1,2082 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { migrateAiModelAccess } from "./model-access-migrate";
|
|
3
|
+
import { withAiShortId } from "./short-id";
|
|
4
|
+
import { migrateAiTurnUsage } from "./usage-migrate";
|
|
5
|
+
|
|
6
|
+
const backfillAiShortIds = async (
|
|
7
|
+
constraint: string,
|
|
8
|
+
rows: { id: string }[],
|
|
9
|
+
update: (id: string, shortId: string) => Promise<unknown>,
|
|
10
|
+
): Promise<void> => {
|
|
11
|
+
for (const row of rows) await withAiShortId(constraint, (shortId) => update(row.id, shortId));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const migrateCloudAi = async (): Promise<void> => {
|
|
15
|
+
await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
|
|
16
|
+
console.log(" ✓ ai schema");
|
|
17
|
+
|
|
18
|
+
await sql`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS ai.conversations (
|
|
20
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
21
|
+
short_id TEXT NOT NULL,
|
|
22
|
+
title TEXT NOT NULL DEFAULT 'New chat',
|
|
23
|
+
description TEXT NOT NULL DEFAULT '',
|
|
24
|
+
loaded_tools TEXT[] NOT NULL DEFAULT '{}',
|
|
25
|
+
draft_content JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
26
|
+
draft_revision BIGINT NOT NULL DEFAULT 0,
|
|
27
|
+
draft_updated_at TIMESTAMPTZ,
|
|
28
|
+
created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
29
|
+
launched_by_app_id TEXT,
|
|
30
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
31
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
32
|
+
archived_at TIMESTAMPTZ
|
|
33
|
+
)
|
|
34
|
+
`.simple();
|
|
35
|
+
|
|
36
|
+
// AI chat ownership was unreleased and only backed local test data. Make the
|
|
37
|
+
// product-model cut once, then keep only the global conversation contract.
|
|
38
|
+
await sql`
|
|
39
|
+
DO $$
|
|
40
|
+
BEGIN
|
|
41
|
+
IF EXISTS (
|
|
42
|
+
SELECT 1 FROM information_schema.columns
|
|
43
|
+
WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'app_id'
|
|
44
|
+
) THEN
|
|
45
|
+
DELETE FROM ai.conversations;
|
|
46
|
+
ALTER TABLE ai.conversations DROP CONSTRAINT IF EXISTS ai_conversations_project_app_fkey;
|
|
47
|
+
DROP INDEX IF EXISTS ai.idx_ai_conversations_app_owner_updated;
|
|
48
|
+
DROP INDEX IF EXISTS ai.idx_ai_conversations_app_owner_pinned;
|
|
49
|
+
ALTER TABLE ai.conversations
|
|
50
|
+
DROP COLUMN app_id,
|
|
51
|
+
DROP COLUMN resource_kind,
|
|
52
|
+
DROP COLUMN resource_app_id,
|
|
53
|
+
DROP COLUMN resource_type,
|
|
54
|
+
DROP COLUMN resource_id;
|
|
55
|
+
END IF;
|
|
56
|
+
END $$
|
|
57
|
+
`.simple();
|
|
58
|
+
|
|
59
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
60
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_conversations_short_id ON ai.conversations(short_id)`.simple();
|
|
61
|
+
await backfillAiShortIds(
|
|
62
|
+
"idx_ai_conversations_short_id",
|
|
63
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.conversations WHERE short_id IS NULL`,
|
|
64
|
+
(id, shortId) => sql`UPDATE ai.conversations SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
65
|
+
);
|
|
66
|
+
await sql`ALTER TABLE ai.conversations ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
67
|
+
|
|
68
|
+
await sql`ALTER TABLE ai.conversations DROP COLUMN IF EXISTS icon`.simple();
|
|
69
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`.simple();
|
|
70
|
+
await sql`
|
|
71
|
+
DO $$
|
|
72
|
+
BEGIN
|
|
73
|
+
IF EXISTS (
|
|
74
|
+
SELECT 1 FROM information_schema.columns
|
|
75
|
+
WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'loaded_capabilities'
|
|
76
|
+
) AND NOT EXISTS (
|
|
77
|
+
SELECT 1 FROM information_schema.columns
|
|
78
|
+
WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'loaded_tools'
|
|
79
|
+
) THEN
|
|
80
|
+
ALTER TABLE ai.conversations RENAME COLUMN loaded_capabilities TO loaded_tools;
|
|
81
|
+
ELSIF EXISTS (
|
|
82
|
+
SELECT 1 FROM information_schema.columns
|
|
83
|
+
WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'loaded_capabilities'
|
|
84
|
+
) THEN
|
|
85
|
+
UPDATE ai.conversations
|
|
86
|
+
SET loaded_tools = ARRAY(
|
|
87
|
+
SELECT DISTINCT name
|
|
88
|
+
FROM unnest(loaded_tools || loaded_capabilities) AS name
|
|
89
|
+
);
|
|
90
|
+
ALTER TABLE ai.conversations DROP COLUMN loaded_capabilities;
|
|
91
|
+
END IF;
|
|
92
|
+
END $$
|
|
93
|
+
`.simple();
|
|
94
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS loaded_tools TEXT[] NOT NULL DEFAULT '{}'`.simple();
|
|
95
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS allowed_tools TEXT[]`.simple();
|
|
96
|
+
// Capability tools used to persist provider-encoded function names. The
|
|
97
|
+
// encoding is reversible for every non-hashed valid capability id; hashed
|
|
98
|
+
// names were only cache state and are safely reloaded through discovery.
|
|
99
|
+
await sql`
|
|
100
|
+
UPDATE ai.conversations
|
|
101
|
+
SET loaded_tools = ARRAY(
|
|
102
|
+
SELECT converted_name
|
|
103
|
+
FROM (
|
|
104
|
+
SELECT
|
|
105
|
+
CASE
|
|
106
|
+
WHEN name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
107
|
+
THEN substring(name FROM '^([a-z][a-z0-9-]*)__') || '.' ||
|
|
108
|
+
replace(
|
|
109
|
+
replace(regexp_replace(name, '^[a-z][a-z0-9-]*__(query|action)__', ''), '_dot_', '.'),
|
|
110
|
+
'__',
|
|
111
|
+
'_'
|
|
112
|
+
)
|
|
113
|
+
ELSE name
|
|
114
|
+
END AS converted_name,
|
|
115
|
+
position
|
|
116
|
+
FROM unnest(loaded_tools) WITH ORDINALITY AS loaded(name, position)
|
|
117
|
+
WHERE NOT (
|
|
118
|
+
length(name) = 64
|
|
119
|
+
AND name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
120
|
+
AND name ~ '__[0-9a-f]{12}$'
|
|
121
|
+
)
|
|
122
|
+
) AS converted
|
|
123
|
+
GROUP BY converted_name
|
|
124
|
+
ORDER BY min(position)
|
|
125
|
+
)
|
|
126
|
+
WHERE EXISTS (
|
|
127
|
+
SELECT 1 FROM unnest(loaded_tools) AS name
|
|
128
|
+
WHERE name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
129
|
+
)
|
|
130
|
+
`.simple();
|
|
131
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS draft_content JSONB NOT NULL DEFAULT '[]'::jsonb`.simple();
|
|
132
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS draft_revision BIGINT NOT NULL DEFAULT 0`.simple();
|
|
133
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS draft_updated_at TIMESTAMPTZ`.simple();
|
|
134
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS launched_by_app_id TEXT`.simple();
|
|
135
|
+
// Enrichment (description/keywords/title upkeep) — dirty = updated_at > enriched_at.
|
|
136
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS keywords TEXT[] NOT NULL DEFAULT '{}'`.simple();
|
|
137
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_summary TEXT NOT NULL DEFAULT ''`.simple();
|
|
138
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS title_source TEXT NOT NULL DEFAULT 'default'`.simple();
|
|
139
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS description_source TEXT NOT NULL DEFAULT 'default'`.simple();
|
|
140
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ`.simple();
|
|
141
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_failed_at TIMESTAMPTZ`.simple();
|
|
142
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_fail_count INTEGER NOT NULL DEFAULT 0`.simple();
|
|
143
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ`.simple();
|
|
144
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS last_viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()`.simple();
|
|
145
|
+
await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET DEFAULT now()`.simple();
|
|
146
|
+
await sql`UPDATE ai.conversations SET last_viewed_at = now() WHERE last_viewed_at IS NULL`.simple();
|
|
147
|
+
await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET NOT NULL`.simple();
|
|
148
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT ''`.simple();
|
|
149
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_document TSVECTOR NOT NULL DEFAULT ''::tsvector`.simple();
|
|
150
|
+
await sql`
|
|
151
|
+
CREATE OR REPLACE FUNCTION ai.refresh_conversation_search() RETURNS trigger AS $$
|
|
152
|
+
BEGIN
|
|
153
|
+
NEW.search_text := COALESCE(NEW.title, '') || ' ' || COALESCE(NEW.title, '') || ' ' ||
|
|
154
|
+
COALESCE(array_to_string(NEW.keywords, ' '), '') || ' ' || COALESCE(NEW.search_summary, '') || ' ' ||
|
|
155
|
+
COALESCE(NEW.description, '');
|
|
156
|
+
NEW.search_document :=
|
|
157
|
+
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
|
|
158
|
+
setweight(to_tsvector('simple', COALESCE(array_to_string(NEW.keywords, ' '), '')), 'A') ||
|
|
159
|
+
setweight(to_tsvector('simple', COALESCE(NEW.search_summary, '')), 'B') ||
|
|
160
|
+
setweight(to_tsvector('simple', COALESCE(NEW.description, '')), 'C');
|
|
161
|
+
RETURN NEW;
|
|
162
|
+
END
|
|
163
|
+
$$ LANGUAGE plpgsql
|
|
164
|
+
`.simple();
|
|
165
|
+
await sql`DROP TRIGGER IF EXISTS ai_conversations_search_refresh ON ai.conversations`.simple();
|
|
166
|
+
await sql`
|
|
167
|
+
CREATE TRIGGER ai_conversations_search_refresh
|
|
168
|
+
BEFORE INSERT OR UPDATE OF title, description, keywords, search_summary
|
|
169
|
+
ON ai.conversations FOR EACH ROW EXECUTE FUNCTION ai.refresh_conversation_search()
|
|
170
|
+
`.simple();
|
|
171
|
+
await sql`UPDATE ai.conversations SET search_summary = search_summary`.simple();
|
|
172
|
+
await sql`
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversations_search_document
|
|
174
|
+
ON ai.conversations USING GIN(search_document)
|
|
175
|
+
`.simple();
|
|
176
|
+
|
|
177
|
+
// Semantics fix: 'auto' is reserved for enrichment-set titles (which always set
|
|
178
|
+
// enriched_at in the same update). First-message snapshot titles are 'default'
|
|
179
|
+
// so enrichment may replace them freely. No-op once the code writes it that way.
|
|
180
|
+
await sql`UPDATE ai.conversations SET title_source = 'default' WHERE title_source = 'auto' AND enriched_at IS NULL`.simple();
|
|
181
|
+
|
|
182
|
+
// The AI summary moved into the user-visible description (guarded by
|
|
183
|
+
// description_source, same pattern as title_source). Migrate stored
|
|
184
|
+
// summaries into empty descriptions, then drop the column.
|
|
185
|
+
await sql`
|
|
186
|
+
DO $$
|
|
187
|
+
BEGIN
|
|
188
|
+
IF EXISTS (
|
|
189
|
+
SELECT 1 FROM information_schema.columns
|
|
190
|
+
WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'summary'
|
|
191
|
+
) THEN
|
|
192
|
+
UPDATE ai.conversations
|
|
193
|
+
SET description = LEFT(summary, 500), description_source = 'auto'
|
|
194
|
+
WHERE description = '' AND summary <> '';
|
|
195
|
+
ALTER TABLE ai.conversations DROP COLUMN summary;
|
|
196
|
+
END IF;
|
|
197
|
+
END $$
|
|
198
|
+
`.simple();
|
|
199
|
+
|
|
200
|
+
await sql`
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversations_enrich_dirty
|
|
202
|
+
ON ai.conversations(updated_at ASC)
|
|
203
|
+
WHERE archived_at IS NULL
|
|
204
|
+
`.simple();
|
|
205
|
+
|
|
206
|
+
// Per-conversation enrichment history — user-visible in the chat settings.
|
|
207
|
+
await sql`
|
|
208
|
+
CREATE TABLE IF NOT EXISTS ai.enrichment_runs (
|
|
209
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
210
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
211
|
+
status TEXT NOT NULL,
|
|
212
|
+
trigger TEXT NOT NULL DEFAULT 'scheduled',
|
|
213
|
+
model_profile_id TEXT,
|
|
214
|
+
mode TEXT,
|
|
215
|
+
duration_ms INTEGER,
|
|
216
|
+
title_updated BOOLEAN NOT NULL DEFAULT FALSE,
|
|
217
|
+
keywords_count INTEGER NOT NULL DEFAULT 0,
|
|
218
|
+
error TEXT,
|
|
219
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
220
|
+
CONSTRAINT ai_enrichment_runs_status_check CHECK (status IN ('ok', 'failed', 'skipped')),
|
|
221
|
+
CONSTRAINT ai_enrichment_runs_trigger_check CHECK (trigger IN ('scheduled', 'manual'))
|
|
222
|
+
)
|
|
223
|
+
`.simple();
|
|
224
|
+
|
|
225
|
+
await sql`
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_ai_enrichment_runs_conversation_created
|
|
227
|
+
ON ai.enrichment_runs(conversation_id, created_at DESC)
|
|
228
|
+
`.simple();
|
|
229
|
+
|
|
230
|
+
// Durable, metadata-only accounting for every runAiStructured call. Traces
|
|
231
|
+
// remain the operational drill-down; this ledger survives trace retention
|
|
232
|
+
// without storing prompts, inputs, or model output.
|
|
233
|
+
await sql`
|
|
234
|
+
CREATE TABLE IF NOT EXISTS ai.structured_runs (
|
|
235
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
236
|
+
task TEXT NOT NULL CHECK (char_length(task) BETWEEN 1 AND 120),
|
|
237
|
+
app_id TEXT,
|
|
238
|
+
model_profile_id TEXT,
|
|
239
|
+
provider_model TEXT,
|
|
240
|
+
status TEXT NOT NULL CHECK (status IN ('ok', 'failed')),
|
|
241
|
+
duration_ms INTEGER NOT NULL CHECK (duration_ms >= 0),
|
|
242
|
+
input_tokens INTEGER CHECK (input_tokens >= 0),
|
|
243
|
+
output_tokens INTEGER CHECK (output_tokens >= 0),
|
|
244
|
+
total_tokens INTEGER CHECK (total_tokens >= 0),
|
|
245
|
+
credits_used DOUBLE PRECISION CHECK (credits_used >= 0),
|
|
246
|
+
mode TEXT,
|
|
247
|
+
repaired BOOLEAN,
|
|
248
|
+
attempts INTEGER CHECK (attempts IS NULL OR attempts >= 1),
|
|
249
|
+
error_code TEXT,
|
|
250
|
+
error TEXT CHECK (error IS NULL OR char_length(error) <= 2000),
|
|
251
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
252
|
+
)
|
|
253
|
+
`.simple();
|
|
254
|
+
await sql`
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_created
|
|
256
|
+
ON ai.structured_runs(created_at DESC)
|
|
257
|
+
`.simple();
|
|
258
|
+
await sql`
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_model_created
|
|
260
|
+
ON ai.structured_runs(model_profile_id, created_at DESC)
|
|
261
|
+
`.simple();
|
|
262
|
+
|
|
263
|
+
await sql`
|
|
264
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversations_owner_updated
|
|
265
|
+
ON ai.conversations(created_by_user_id, updated_at DESC)
|
|
266
|
+
WHERE archived_at IS NULL
|
|
267
|
+
`.simple();
|
|
268
|
+
|
|
269
|
+
await sql`
|
|
270
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversations_owner_pinned
|
|
271
|
+
ON ai.conversations(created_by_user_id, pinned_at DESC, updated_at DESC)
|
|
272
|
+
WHERE archived_at IS NULL
|
|
273
|
+
`.simple();
|
|
274
|
+
|
|
275
|
+
await sql`
|
|
276
|
+
CREATE TABLE IF NOT EXISTS ai.messages (
|
|
277
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
278
|
+
short_id TEXT NOT NULL,
|
|
279
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
280
|
+
seq INTEGER NOT NULL,
|
|
281
|
+
kind TEXT NOT NULL DEFAULT 'message',
|
|
282
|
+
role TEXT NOT NULL,
|
|
283
|
+
message JSONB NOT NULL,
|
|
284
|
+
model_profile_id TEXT,
|
|
285
|
+
provider_model TEXT,
|
|
286
|
+
usage JSONB,
|
|
287
|
+
stop_reason TEXT,
|
|
288
|
+
loop_id TEXT,
|
|
289
|
+
loop_aggregate JSONB,
|
|
290
|
+
loop_done_reason TEXT,
|
|
291
|
+
compacted_at TIMESTAMPTZ,
|
|
292
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
293
|
+
CONSTRAINT ai_messages_kind_check CHECK (kind IN ('message', 'summary')),
|
|
294
|
+
CONSTRAINT ai_messages_role_check CHECK (role IN ('user', 'assistant', 'tool_result'))
|
|
295
|
+
)
|
|
296
|
+
`.simple();
|
|
297
|
+
|
|
298
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
299
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_messages_conversation_short_id ON ai.messages(conversation_id, short_id)`.simple();
|
|
300
|
+
await backfillAiShortIds(
|
|
301
|
+
"idx_ai_messages_conversation_short_id",
|
|
302
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.messages WHERE short_id IS NULL`,
|
|
303
|
+
(id, shortId) => sql`UPDATE ai.messages SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
304
|
+
);
|
|
305
|
+
await sql`ALTER TABLE ai.messages ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
306
|
+
|
|
307
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS compacted_at TIMESTAMPTZ`.simple();
|
|
308
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS loop_id TEXT`.simple();
|
|
309
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS loop_aggregate JSONB`.simple();
|
|
310
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS loop_done_reason TEXT`.simple();
|
|
311
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS meta JSONB`.simple();
|
|
312
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_rating SMALLINT`.simple();
|
|
313
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_reasons TEXT[] NOT NULL DEFAULT '{}'`.simple();
|
|
314
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_comment TEXT`.simple();
|
|
315
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS feedback_updated_at TIMESTAMPTZ`.simple();
|
|
316
|
+
await sql`
|
|
317
|
+
ALTER TABLE ai.messages DROP CONSTRAINT IF EXISTS ai_messages_feedback_check;
|
|
318
|
+
ALTER TABLE ai.messages ADD CONSTRAINT ai_messages_feedback_check CHECK (
|
|
319
|
+
(feedback_rating IS NULL AND cardinality(feedback_reasons) = 0 AND feedback_comment IS NULL AND feedback_updated_at IS NULL)
|
|
320
|
+
OR (feedback_rating = 1 AND cardinality(feedback_reasons) = 0 AND feedback_comment IS NULL AND feedback_updated_at IS NOT NULL)
|
|
321
|
+
OR (
|
|
322
|
+
feedback_rating = -1
|
|
323
|
+
AND feedback_updated_at IS NOT NULL
|
|
324
|
+
AND (cardinality(feedback_reasons) > 0 OR feedback_comment IS NOT NULL)
|
|
325
|
+
AND feedback_reasons <@ ARRAY[
|
|
326
|
+
'incorrect', 'did_not_follow_request', 'incomplete', 'poor_tool_choice', 'too_slow', 'other'
|
|
327
|
+
]::text[]
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
`.simple();
|
|
331
|
+
await sql`
|
|
332
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_assistant_created
|
|
333
|
+
ON ai.messages(created_at DESC)
|
|
334
|
+
WHERE role = 'assistant' AND kind = 'message'
|
|
335
|
+
`.simple();
|
|
336
|
+
await sql`
|
|
337
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_feedback_updated
|
|
338
|
+
ON ai.messages(feedback_updated_at DESC)
|
|
339
|
+
WHERE feedback_rating IS NOT NULL
|
|
340
|
+
`.simple();
|
|
341
|
+
await sql`ALTER TABLE ai.messages ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT ''`.simple();
|
|
342
|
+
await sql`
|
|
343
|
+
ALTER TABLE ai.messages
|
|
344
|
+
ADD COLUMN IF NOT EXISTS search_document tsvector
|
|
345
|
+
GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(search_text, ''))) STORED
|
|
346
|
+
`.simple();
|
|
347
|
+
await sql`
|
|
348
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_search_document
|
|
349
|
+
ON ai.messages USING GIN(search_document)
|
|
350
|
+
`.simple();
|
|
351
|
+
|
|
352
|
+
// Seq is only unique among active (non-compacted) rows: compaction archives rows
|
|
353
|
+
// in place and the summary takes over the checkpoint seq.
|
|
354
|
+
await sql`ALTER TABLE ai.messages DROP CONSTRAINT IF EXISTS ai_messages_seq_unique`.simple();
|
|
355
|
+
await sql`
|
|
356
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_messages_active_seq_unique
|
|
357
|
+
ON ai.messages(conversation_id, seq)
|
|
358
|
+
WHERE compacted_at IS NULL
|
|
359
|
+
`.simple();
|
|
360
|
+
|
|
361
|
+
await sql`
|
|
362
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation_seq
|
|
363
|
+
ON ai.messages(conversation_id, seq ASC)
|
|
364
|
+
`.simple();
|
|
365
|
+
|
|
366
|
+
await sql`
|
|
367
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_active_conversation_seq
|
|
368
|
+
ON ai.messages(conversation_id, seq ASC)
|
|
369
|
+
WHERE compacted_at IS NULL
|
|
370
|
+
`.simple();
|
|
371
|
+
|
|
372
|
+
await sql`
|
|
373
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_active_conversation_loop_seq
|
|
374
|
+
ON ai.messages(conversation_id, loop_id, seq ASC)
|
|
375
|
+
WHERE compacted_at IS NULL AND loop_id IS NOT NULL
|
|
376
|
+
`.simple();
|
|
377
|
+
|
|
378
|
+
// Optional pg_textsearch ranking. Native weighted FTS above is always the
|
|
379
|
+
// correctness path; missing extension support must never block startup.
|
|
380
|
+
try {
|
|
381
|
+
const [extension] = await sql<{ available: boolean; installed: boolean; server_version: number }[]>`
|
|
382
|
+
SELECT
|
|
383
|
+
EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_textsearch') AS available,
|
|
384
|
+
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed,
|
|
385
|
+
current_setting('server_version_num')::int AS server_version
|
|
386
|
+
`;
|
|
387
|
+
let installed = extension?.installed ?? false;
|
|
388
|
+
if (!installed && extension?.available && extension.server_version >= 170000) {
|
|
389
|
+
await sql`CREATE EXTENSION IF NOT EXISTS pg_textsearch`.simple();
|
|
390
|
+
installed = true;
|
|
391
|
+
}
|
|
392
|
+
if (installed) {
|
|
393
|
+
await sql
|
|
394
|
+
.unsafe(`
|
|
395
|
+
CREATE INDEX IF NOT EXISTS conversations_search_bm25_idx
|
|
396
|
+
ON ai.conversations USING bm25 ((search_text)) WITH (text_config='simple')
|
|
397
|
+
`)
|
|
398
|
+
.simple();
|
|
399
|
+
await sql
|
|
400
|
+
.unsafe(`
|
|
401
|
+
CREATE INDEX IF NOT EXISTS messages_search_bm25_idx
|
|
402
|
+
ON ai.messages USING bm25 ((search_text)) WITH (text_config='simple')
|
|
403
|
+
`)
|
|
404
|
+
.simple();
|
|
405
|
+
console.log(" ✓ ai conversation optional BM25 search indexes");
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
console.warn(" ! ai conversation optional BM25 search unavailable; native PostgreSQL FTS remains active", error);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
await sql`
|
|
412
|
+
CREATE TABLE IF NOT EXISTS ai.turns (
|
|
413
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
414
|
+
short_id TEXT NOT NULL,
|
|
415
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
416
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
417
|
+
model_profile_id TEXT,
|
|
418
|
+
attempt INTEGER NOT NULL DEFAULT 0,
|
|
419
|
+
live_blocks JSONB,
|
|
420
|
+
live_seq BIGINT NOT NULL DEFAULT 0,
|
|
421
|
+
deadline TIMESTAMPTZ,
|
|
422
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
423
|
+
completed_at TIMESTAMPTZ,
|
|
424
|
+
error TEXT,
|
|
425
|
+
cancel_requested_at TIMESTAMPTZ,
|
|
426
|
+
cancellation_reason TEXT,
|
|
427
|
+
lease_owner TEXT,
|
|
428
|
+
lease_expires_at TIMESTAMPTZ,
|
|
429
|
+
heartbeat_at TIMESTAMPTZ,
|
|
430
|
+
run_config JSONB,
|
|
431
|
+
CONSTRAINT ai_turns_status_check CHECK (status IN ('queued', 'running', 'waiting_for_action', 'completed', 'failed', 'aborted'))
|
|
432
|
+
)
|
|
433
|
+
`.simple();
|
|
434
|
+
|
|
435
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
436
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_conversation_short_id ON ai.turns(conversation_id, short_id)`.simple();
|
|
437
|
+
await backfillAiShortIds(
|
|
438
|
+
"idx_ai_turns_conversation_short_id",
|
|
439
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.turns WHERE short_id IS NULL`,
|
|
440
|
+
(id, shortId) => sql`UPDATE ai.turns SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
441
|
+
);
|
|
442
|
+
await sql`ALTER TABLE ai.turns ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
443
|
+
|
|
444
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ`.simple();
|
|
445
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS cancellation_reason TEXT`.simple();
|
|
446
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS lease_owner TEXT`.simple();
|
|
447
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ`.simple();
|
|
448
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ`.simple();
|
|
449
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS run_config JSONB`.simple();
|
|
450
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 0`.simple();
|
|
451
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_blocks JSONB`.simple();
|
|
452
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_seq BIGINT NOT NULL DEFAULT 0`.simple();
|
|
453
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS deadline TIMESTAMPTZ`.simple();
|
|
454
|
+
await sql`ALTER TABLE ai.turns ALTER COLUMN status SET DEFAULT 'queued'`.simple();
|
|
455
|
+
await sql`
|
|
456
|
+
UPDATE ai.turns
|
|
457
|
+
SET run_config = (run_config #>> '{}')::jsonb
|
|
458
|
+
WHERE jsonb_typeof(run_config) = 'string'
|
|
459
|
+
`.simple();
|
|
460
|
+
await sql`
|
|
461
|
+
UPDATE ai.turns
|
|
462
|
+
SET live_blocks = (live_blocks #>> '{}')::jsonb
|
|
463
|
+
WHERE jsonb_typeof(live_blocks) = 'string'
|
|
464
|
+
`.simple();
|
|
465
|
+
|
|
466
|
+
await sql`
|
|
467
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_one_active_per_conversation
|
|
468
|
+
ON ai.turns(conversation_id)
|
|
469
|
+
WHERE status IN ('queued', 'running', 'waiting_for_action')
|
|
470
|
+
`.simple();
|
|
471
|
+
|
|
472
|
+
await sql`
|
|
473
|
+
CREATE INDEX IF NOT EXISTS idx_ai_turns_conversation_created
|
|
474
|
+
ON ai.turns(conversation_id, created_at DESC)
|
|
475
|
+
`.simple();
|
|
476
|
+
|
|
477
|
+
await sql`
|
|
478
|
+
CREATE INDEX IF NOT EXISTS idx_ai_turns_completed_chat
|
|
479
|
+
ON ai.turns(completed_at, id)
|
|
480
|
+
WHERE status = 'completed' AND run_config->>'kind' = 'chat'
|
|
481
|
+
`.simple();
|
|
482
|
+
|
|
483
|
+
await sql`
|
|
484
|
+
CREATE TABLE IF NOT EXISTS ai.conversation_resource_refs (
|
|
485
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
486
|
+
resource_type TEXT NOT NULL,
|
|
487
|
+
resource_id TEXT NOT NULL,
|
|
488
|
+
title TEXT,
|
|
489
|
+
preview TEXT,
|
|
490
|
+
icon TEXT,
|
|
491
|
+
href TEXT,
|
|
492
|
+
source_turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
|
|
493
|
+
source_call_id TEXT,
|
|
494
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
495
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
496
|
+
PRIMARY KEY (conversation_id, resource_type, resource_id)
|
|
497
|
+
)
|
|
498
|
+
`.simple();
|
|
499
|
+
await sql`ALTER TABLE ai.conversation_resource_refs DROP COLUMN IF EXISTS occurrences`.simple();
|
|
500
|
+
|
|
501
|
+
await sql`
|
|
502
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversation_resource_refs_recent
|
|
503
|
+
ON ai.conversation_resource_refs(conversation_id, last_seen_at DESC, resource_type, resource_id)
|
|
504
|
+
`.simple();
|
|
505
|
+
|
|
506
|
+
await sql`
|
|
507
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversation_resource_refs_resource
|
|
508
|
+
ON ai.conversation_resource_refs(resource_type, resource_id, conversation_id)
|
|
509
|
+
`.simple();
|
|
510
|
+
|
|
511
|
+
await sql`
|
|
512
|
+
CREATE TABLE IF NOT EXISTS ai.conversation_sources (
|
|
513
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
514
|
+
kind TEXT NOT NULL,
|
|
515
|
+
source_key TEXT NOT NULL,
|
|
516
|
+
title TEXT NOT NULL,
|
|
517
|
+
preview TEXT,
|
|
518
|
+
icon TEXT,
|
|
519
|
+
href TEXT,
|
|
520
|
+
occurrences INTEGER NOT NULL DEFAULT 1,
|
|
521
|
+
source_turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
|
|
522
|
+
source_call_id TEXT,
|
|
523
|
+
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
524
|
+
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
525
|
+
PRIMARY KEY (conversation_id, kind, source_key),
|
|
526
|
+
CONSTRAINT ai_conversation_sources_kind_check CHECK (kind IN ('web', 'activity')),
|
|
527
|
+
CONSTRAINT ai_conversation_sources_occurrences_check CHECK (occurrences > 0)
|
|
528
|
+
)
|
|
529
|
+
`.simple();
|
|
530
|
+
|
|
531
|
+
await sql`
|
|
532
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversation_sources_recent
|
|
533
|
+
ON ai.conversation_sources(conversation_id, last_seen_at DESC, kind, source_key)
|
|
534
|
+
`.simple();
|
|
535
|
+
|
|
536
|
+
await sql`
|
|
537
|
+
CREATE TABLE IF NOT EXISTS ai.turn_steers (
|
|
538
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
539
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
540
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
541
|
+
seq INTEGER NOT NULL,
|
|
542
|
+
client_request_id TEXT NOT NULL,
|
|
543
|
+
text TEXT NOT NULL,
|
|
544
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
545
|
+
message_id UUID REFERENCES ai.messages(id) ON DELETE SET NULL,
|
|
546
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
547
|
+
consumed_at TIMESTAMPTZ,
|
|
548
|
+
CONSTRAINT ai_turn_steers_status_check CHECK (status IN ('pending', 'consumed', 'discarded')),
|
|
549
|
+
CONSTRAINT ai_turn_steers_text_check CHECK (length(btrim(text)) BETWEEN 1 AND 20000)
|
|
550
|
+
)
|
|
551
|
+
`.simple();
|
|
552
|
+
|
|
553
|
+
await sql`
|
|
554
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turn_steers_turn_seq
|
|
555
|
+
ON ai.turn_steers(turn_id, seq)
|
|
556
|
+
`.simple();
|
|
557
|
+
|
|
558
|
+
await sql`
|
|
559
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turn_steers_request
|
|
560
|
+
ON ai.turn_steers(turn_id, client_request_id)
|
|
561
|
+
`.simple();
|
|
562
|
+
|
|
563
|
+
await sql`
|
|
564
|
+
CREATE INDEX IF NOT EXISTS idx_ai_turn_steers_pending
|
|
565
|
+
ON ai.turn_steers(conversation_id, turn_id, seq ASC)
|
|
566
|
+
WHERE status = 'pending'
|
|
567
|
+
`.simple();
|
|
568
|
+
|
|
569
|
+
// Streaming persistence moved to live_blocks snapshots plus the Redis fanout
|
|
570
|
+
// topic; the per-delta event log is gone.
|
|
571
|
+
await sql`DROP TABLE IF EXISTS ai.turn_events`.simple();
|
|
572
|
+
|
|
573
|
+
await sql`
|
|
574
|
+
CREATE TABLE IF NOT EXISTS ai.pending_actions (
|
|
575
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
576
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
577
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
578
|
+
call_id TEXT NOT NULL,
|
|
579
|
+
kind TEXT NOT NULL,
|
|
580
|
+
tool_name TEXT NOT NULL,
|
|
581
|
+
args JSONB NOT NULL,
|
|
582
|
+
message TEXT,
|
|
583
|
+
review JSONB,
|
|
584
|
+
approval_scope TEXT NOT NULL,
|
|
585
|
+
allow_always BOOLEAN NOT NULL DEFAULT FALSE,
|
|
586
|
+
frontend_mode TEXT,
|
|
587
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
588
|
+
resolved_event JSONB,
|
|
589
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
590
|
+
resolved_at TIMESTAMPTZ,
|
|
591
|
+
CONSTRAINT ai_pending_actions_kind_check CHECK (kind IN ('approval', 'custom_approval', 'client_tool')),
|
|
592
|
+
CONSTRAINT ai_pending_actions_status_check CHECK (status IN ('pending', 'resolved', 'aborted'))
|
|
593
|
+
)
|
|
594
|
+
`.simple();
|
|
595
|
+
|
|
596
|
+
await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
|
|
597
|
+
|
|
598
|
+
await sql`
|
|
599
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
|
|
600
|
+
ON ai.pending_actions(turn_id, call_id)
|
|
601
|
+
`.simple();
|
|
602
|
+
|
|
603
|
+
await sql`
|
|
604
|
+
CREATE INDEX IF NOT EXISTS idx_ai_pending_actions_pending_turn
|
|
605
|
+
ON ai.pending_actions(conversation_id, turn_id, created_at ASC)
|
|
606
|
+
WHERE status = 'pending'
|
|
607
|
+
`.simple();
|
|
608
|
+
|
|
609
|
+
await sql`
|
|
610
|
+
DO $$
|
|
611
|
+
BEGIN
|
|
612
|
+
IF EXISTS (
|
|
613
|
+
SELECT 1 FROM information_schema.columns
|
|
614
|
+
WHERE table_schema = 'ai' AND table_name = 'tool_approval_preferences' AND column_name = 'app_id'
|
|
615
|
+
) THEN
|
|
616
|
+
DROP TABLE ai.tool_approval_preferences;
|
|
617
|
+
END IF;
|
|
618
|
+
END $$
|
|
619
|
+
`.simple();
|
|
620
|
+
|
|
621
|
+
await sql`
|
|
622
|
+
CREATE TABLE IF NOT EXISTS ai.tool_approval_preferences (
|
|
623
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
624
|
+
actor_user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
625
|
+
tool_name TEXT NOT NULL,
|
|
626
|
+
approval_scope TEXT NOT NULL,
|
|
627
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
628
|
+
last_used_at TIMESTAMPTZ,
|
|
629
|
+
expires_at TIMESTAMPTZ,
|
|
630
|
+
CONSTRAINT ai_tool_approval_preferences_unique UNIQUE (actor_user_id, tool_name, approval_scope)
|
|
631
|
+
)
|
|
632
|
+
`.simple();
|
|
633
|
+
|
|
634
|
+
// Remembered capability approvals use the same stable qualified identity as
|
|
635
|
+
// loaded tools. Revoke the rare hashed legacy identity rather than guessing.
|
|
636
|
+
await sql`
|
|
637
|
+
INSERT INTO ai.tool_approval_preferences (
|
|
638
|
+
id, actor_user_id, tool_name, approval_scope, created_at, last_used_at, expires_at
|
|
639
|
+
)
|
|
640
|
+
SELECT
|
|
641
|
+
gen_random_uuid(),
|
|
642
|
+
actor_user_id,
|
|
643
|
+
substring(tool_name FROM '^([a-z][a-z0-9-]*)__') || '.' ||
|
|
644
|
+
replace(
|
|
645
|
+
replace(regexp_replace(tool_name, '^[a-z][a-z0-9-]*__(query|action)__', ''), '_dot_', '.'),
|
|
646
|
+
'__',
|
|
647
|
+
'_'
|
|
648
|
+
),
|
|
649
|
+
approval_scope,
|
|
650
|
+
created_at,
|
|
651
|
+
last_used_at,
|
|
652
|
+
expires_at
|
|
653
|
+
FROM ai.tool_approval_preferences
|
|
654
|
+
WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
655
|
+
AND NOT (length(tool_name) = 64 AND tool_name ~ '__[0-9a-f]{12}$')
|
|
656
|
+
ON CONFLICT (actor_user_id, tool_name, approval_scope) DO NOTHING
|
|
657
|
+
`.simple();
|
|
658
|
+
await sql`
|
|
659
|
+
DELETE FROM ai.tool_approval_preferences
|
|
660
|
+
WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
|
|
661
|
+
`.simple();
|
|
662
|
+
|
|
663
|
+
await sql`
|
|
664
|
+
CREATE TABLE IF NOT EXISTS ai.tool_calls (
|
|
665
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
666
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
667
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
668
|
+
call_id TEXT NOT NULL,
|
|
669
|
+
tool_name TEXT NOT NULL,
|
|
670
|
+
location TEXT NOT NULL DEFAULT 'server',
|
|
671
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
672
|
+
approval_state TEXT NOT NULL DEFAULT 'not_required',
|
|
673
|
+
input_meta JSONB,
|
|
674
|
+
output_meta JSONB,
|
|
675
|
+
error TEXT,
|
|
676
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
677
|
+
started_at TIMESTAMPTZ,
|
|
678
|
+
approval_requested_at TIMESTAMPTZ,
|
|
679
|
+
approved_at TIMESTAMPTZ,
|
|
680
|
+
rejected_at TIMESTAMPTZ,
|
|
681
|
+
completed_at TIMESTAMPTZ,
|
|
682
|
+
CONSTRAINT ai_tool_calls_status_check CHECK (status IN ('pending', 'running', 'waiting_for_approval', 'waiting_for_frontend', 'completed', 'failed', 'rejected')),
|
|
683
|
+
CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_always', 'approved_by_preference', 'rejected'))
|
|
684
|
+
)
|
|
685
|
+
`.simple();
|
|
686
|
+
|
|
687
|
+
await sql`
|
|
688
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_turn_call
|
|
689
|
+
ON ai.tool_calls(turn_id, call_id)
|
|
690
|
+
`.simple();
|
|
691
|
+
|
|
692
|
+
await sql`
|
|
693
|
+
CREATE INDEX IF NOT EXISTS idx_ai_tool_calls_conversation_created
|
|
694
|
+
ON ai.tool_calls(conversation_id, created_at DESC)
|
|
695
|
+
`.simple();
|
|
696
|
+
|
|
697
|
+
await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
|
|
698
|
+
await sql`
|
|
699
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_idempotency_key
|
|
700
|
+
ON ai.tool_calls(idempotency_key)
|
|
701
|
+
WHERE idempotency_key IS NOT NULL
|
|
702
|
+
`.simple();
|
|
703
|
+
|
|
704
|
+
await sql`
|
|
705
|
+
CREATE TABLE IF NOT EXISTS ai.inter_chat_messages (
|
|
706
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
707
|
+
short_id TEXT NOT NULL,
|
|
708
|
+
source_conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
709
|
+
source_turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
710
|
+
source_call_id TEXT NOT NULL,
|
|
711
|
+
target_conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
712
|
+
actor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
713
|
+
text TEXT NOT NULL,
|
|
714
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
715
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
716
|
+
target_turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
|
|
717
|
+
target_message_id UUID REFERENCES ai.messages(id) ON DELETE SET NULL,
|
|
718
|
+
error TEXT,
|
|
719
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
720
|
+
delivered_at TIMESTAMPTZ,
|
|
721
|
+
CONSTRAINT ai_inter_chat_messages_short_id_unique UNIQUE (short_id),
|
|
722
|
+
CONSTRAINT ai_inter_chat_messages_status_check CHECK (status IN ('pending', 'delivered', 'failed')),
|
|
723
|
+
CONSTRAINT ai_inter_chat_messages_text_check CHECK (length(btrim(text)) BETWEEN 1 AND 20000),
|
|
724
|
+
CONSTRAINT ai_inter_chat_messages_distinct_chats_check CHECK (source_conversation_id <> target_conversation_id)
|
|
725
|
+
)
|
|
726
|
+
`.simple();
|
|
727
|
+
|
|
728
|
+
await sql`
|
|
729
|
+
CREATE INDEX IF NOT EXISTS idx_ai_inter_chat_messages_pending_target
|
|
730
|
+
ON ai.inter_chat_messages(target_conversation_id, created_at ASC, id ASC)
|
|
731
|
+
WHERE status = 'pending'
|
|
732
|
+
`.simple();
|
|
733
|
+
|
|
734
|
+
await sql`
|
|
735
|
+
CREATE TABLE IF NOT EXISTS ai.chat_tasks (
|
|
736
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
737
|
+
short_id TEXT NOT NULL,
|
|
738
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
739
|
+
sponsor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
740
|
+
prompt TEXT NOT NULL,
|
|
741
|
+
schedule_kind TEXT NOT NULL,
|
|
742
|
+
run_at TIMESTAMPTZ,
|
|
743
|
+
cron TEXT,
|
|
744
|
+
timezone TEXT NOT NULL,
|
|
745
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
746
|
+
revision BIGINT NOT NULL DEFAULT 0,
|
|
747
|
+
mandate_id UUID,
|
|
748
|
+
mandate_revision BIGINT,
|
|
749
|
+
last_error TEXT,
|
|
750
|
+
idempotency_key TEXT,
|
|
751
|
+
idempotency_fingerprint TEXT,
|
|
752
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
753
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
754
|
+
CONSTRAINT ai_chat_tasks_short_id_unique UNIQUE (short_id),
|
|
755
|
+
CONSTRAINT ai_chat_tasks_prompt_check CHECK (length(btrim(prompt)) BETWEEN 1 AND 10000),
|
|
756
|
+
CONSTRAINT ai_chat_tasks_schedule_kind_check CHECK (schedule_kind IN ('once', 'cron')),
|
|
757
|
+
CONSTRAINT ai_chat_tasks_schedule_check CHECK (
|
|
758
|
+
(schedule_kind = 'once' AND run_at IS NOT NULL AND cron IS NULL) OR
|
|
759
|
+
(schedule_kind = 'cron' AND run_at IS NULL AND cron IS NOT NULL)
|
|
760
|
+
),
|
|
761
|
+
CONSTRAINT ai_chat_tasks_state_check CHECK (state IN ('active', 'paused', 'completed', 'needs_attention'))
|
|
762
|
+
)
|
|
763
|
+
`.simple();
|
|
764
|
+
|
|
765
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS idempotency_fingerprint TEXT`.simple();
|
|
766
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0`.simple();
|
|
767
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_id UUID`.simple();
|
|
768
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_revision BIGINT`.simple();
|
|
769
|
+
await sql`
|
|
770
|
+
DO $$
|
|
771
|
+
BEGIN
|
|
772
|
+
IF EXISTS (
|
|
773
|
+
SELECT 1 FROM pg_constraint
|
|
774
|
+
WHERE conname = 'ai_chat_tasks_mandate_fkey' AND confdeltype <> 'n'
|
|
775
|
+
) THEN
|
|
776
|
+
ALTER TABLE ai.chat_tasks DROP CONSTRAINT ai_chat_tasks_mandate_fkey;
|
|
777
|
+
END IF;
|
|
778
|
+
IF NOT EXISTS (
|
|
779
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_mandate_fkey'
|
|
780
|
+
) THEN
|
|
781
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_mandate_fkey
|
|
782
|
+
FOREIGN KEY (mandate_id) REFERENCES auth.mandates(id) ON DELETE SET NULL;
|
|
783
|
+
END IF;
|
|
784
|
+
END $$
|
|
785
|
+
`.simple();
|
|
786
|
+
// Existing tasks are upgraded under a row lock when first reconciled or run.
|
|
787
|
+
// Avoid an unbounded migration and never fall back to an interactive session.
|
|
788
|
+
await sql`ALTER TABLE ai.chat_tasks ALTER COLUMN mandate_id DROP NOT NULL`.simple();
|
|
789
|
+
await sql`ALTER TABLE ai.chat_tasks ALTER COLUMN mandate_revision DROP NOT NULL`.simple();
|
|
790
|
+
await sql`
|
|
791
|
+
CREATE OR REPLACE FUNCTION ai.clear_chat_task_mandate_revision() RETURNS trigger AS $$
|
|
792
|
+
BEGIN
|
|
793
|
+
IF NEW.mandate_id IS NULL THEN NEW.mandate_revision := NULL; END IF;
|
|
794
|
+
RETURN NEW;
|
|
795
|
+
END
|
|
796
|
+
$$ LANGUAGE plpgsql
|
|
797
|
+
`.simple();
|
|
798
|
+
await sql`DROP TRIGGER IF EXISTS ai_chat_task_mandate_clear ON ai.chat_tasks`.simple();
|
|
799
|
+
await sql`
|
|
800
|
+
CREATE TRIGGER ai_chat_task_mandate_clear
|
|
801
|
+
BEFORE UPDATE OF mandate_id ON ai.chat_tasks
|
|
802
|
+
FOR EACH ROW EXECUTE FUNCTION ai.clear_chat_task_mandate_revision()
|
|
803
|
+
`.simple();
|
|
804
|
+
await sql`
|
|
805
|
+
CREATE OR REPLACE FUNCTION ai.revoke_chat_task_mandate() RETURNS trigger AS $$
|
|
806
|
+
DECLARE
|
|
807
|
+
revoked_count INTEGER;
|
|
808
|
+
BEGIN
|
|
809
|
+
UPDATE auth.mandates
|
|
810
|
+
SET state = 'revoked', revision = revision + 1, updated_at = now(), revoked_at = now(),
|
|
811
|
+
revoked_by_app_id = 'core', revoke_reason = 'Scheduled chat task deleted'
|
|
812
|
+
WHERE id = OLD.mandate_id AND state <> 'revoked';
|
|
813
|
+
GET DIAGNOSTICS revoked_count = ROW_COUNT;
|
|
814
|
+
IF revoked_count = 1 THEN
|
|
815
|
+
INSERT INTO audit.events (
|
|
816
|
+
action, outcome, target_type, target_id, reason, metadata
|
|
817
|
+
) VALUES (
|
|
818
|
+
'mandate.revoke', 'allowed', 'mandate', OLD.mandate_id::text,
|
|
819
|
+
'Scheduled chat task deleted',
|
|
820
|
+
jsonb_build_object(
|
|
821
|
+
'ownerAppId', 'core',
|
|
822
|
+
'workloadType', 'ai.chat-task',
|
|
823
|
+
'workloadId', OLD.id::text,
|
|
824
|
+
'provenance', 'ai.chat-task-cascade'
|
|
825
|
+
)
|
|
826
|
+
);
|
|
827
|
+
END IF;
|
|
828
|
+
RETURN OLD;
|
|
829
|
+
END
|
|
830
|
+
$$ LANGUAGE plpgsql
|
|
831
|
+
`.simple();
|
|
832
|
+
await sql`DROP TRIGGER IF EXISTS ai_chat_task_mandate_revoke ON ai.chat_tasks`.simple();
|
|
833
|
+
await sql`
|
|
834
|
+
CREATE TRIGGER ai_chat_task_mandate_revoke
|
|
835
|
+
BEFORE DELETE ON ai.chat_tasks
|
|
836
|
+
FOR EACH ROW EXECUTE FUNCTION ai.revoke_chat_task_mandate()
|
|
837
|
+
`.simple();
|
|
838
|
+
await sql`ALTER TABLE ai.chat_tasks DROP CONSTRAINT IF EXISTS chat_tasks_idempotency_key_key`.simple();
|
|
839
|
+
await sql`
|
|
840
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_tasks_owner_idempotency
|
|
841
|
+
ON ai.chat_tasks(sponsor_user_id, idempotency_key)
|
|
842
|
+
WHERE idempotency_key IS NOT NULL
|
|
843
|
+
`.simple();
|
|
844
|
+
|
|
845
|
+
await sql`
|
|
846
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chat_tasks_owner_created
|
|
847
|
+
ON ai.chat_tasks(sponsor_user_id, created_at DESC, id DESC)
|
|
848
|
+
`.simple();
|
|
849
|
+
|
|
850
|
+
await sql`
|
|
851
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chat_tasks_due_once
|
|
852
|
+
ON ai.chat_tasks(run_at ASC, id ASC)
|
|
853
|
+
WHERE state = 'active' AND schedule_kind = 'once'
|
|
854
|
+
`.simple();
|
|
855
|
+
|
|
856
|
+
await sql`
|
|
857
|
+
CREATE TABLE IF NOT EXISTS ai.chat_task_occurrences (
|
|
858
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
859
|
+
short_id TEXT NOT NULL,
|
|
860
|
+
task_id UUID NOT NULL REFERENCES ai.chat_tasks(id) ON DELETE CASCADE,
|
|
861
|
+
scheduled_for TIMESTAMPTZ NOT NULL,
|
|
862
|
+
trigger TEXT NOT NULL DEFAULT 'scheduled',
|
|
863
|
+
state TEXT NOT NULL DEFAULT 'queued',
|
|
864
|
+
task_revision BIGINT NOT NULL DEFAULT 0,
|
|
865
|
+
request_key TEXT NOT NULL,
|
|
866
|
+
turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
|
|
867
|
+
error TEXT,
|
|
868
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
869
|
+
started_at TIMESTAMPTZ,
|
|
870
|
+
completed_at TIMESTAMPTZ,
|
|
871
|
+
CONSTRAINT ai_chat_task_occurrences_short_id_unique UNIQUE (short_id),
|
|
872
|
+
CONSTRAINT ai_chat_task_occurrences_slot_unique UNIQUE (task_id, scheduled_for),
|
|
873
|
+
CONSTRAINT ai_chat_task_occurrences_trigger_check CHECK (trigger IN ('scheduled', 'manual')),
|
|
874
|
+
CONSTRAINT ai_chat_task_occurrences_state_check CHECK (state IN ('queued', 'running', 'completed', 'failed'))
|
|
875
|
+
)
|
|
876
|
+
`.simple();
|
|
877
|
+
|
|
878
|
+
await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS task_revision BIGINT NOT NULL DEFAULT 0`.simple();
|
|
879
|
+
|
|
880
|
+
await sql`
|
|
881
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_request_key
|
|
882
|
+
ON ai.chat_task_occurrences(request_key)
|
|
883
|
+
`.simple();
|
|
884
|
+
|
|
885
|
+
await sql`
|
|
886
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_queued
|
|
887
|
+
ON ai.chat_task_occurrences(created_at ASC, id ASC)
|
|
888
|
+
WHERE state = 'queued'
|
|
889
|
+
`.simple();
|
|
890
|
+
|
|
891
|
+
await sql`
|
|
892
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_active_task
|
|
893
|
+
ON ai.chat_task_occurrences(task_id)
|
|
894
|
+
WHERE state IN ('queued', 'running')
|
|
895
|
+
`.simple();
|
|
896
|
+
|
|
897
|
+
await sql`
|
|
898
|
+
CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_task_created
|
|
899
|
+
ON ai.chat_task_occurrences(task_id, created_at DESC, id DESC)
|
|
900
|
+
`.simple();
|
|
901
|
+
|
|
902
|
+
await sql`
|
|
903
|
+
CREATE TABLE IF NOT EXISTS ai.user_prefs (
|
|
904
|
+
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
905
|
+
memory_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
906
|
+
memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
907
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
908
|
+
)
|
|
909
|
+
`.simple();
|
|
910
|
+
|
|
911
|
+
// Unreleased alpha cut: structured records replace the former free-form
|
|
912
|
+
// memory blob. Deliberately discard it instead of migrating ambiguous lines.
|
|
913
|
+
await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS memory`.simple();
|
|
914
|
+
await sql`ALTER TABLE ai.user_prefs DROP COLUMN IF EXISTS instructions`.simple();
|
|
915
|
+
await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS memory_learning_enabled BOOLEAN NOT NULL DEFAULT FALSE`.simple();
|
|
916
|
+
|
|
917
|
+
// Last model the user actually ran a turn with — preselected for new chats.
|
|
918
|
+
await sql`ALTER TABLE ai.user_prefs ADD COLUMN IF NOT EXISTS last_model_id TEXT NOT NULL DEFAULT ''`.simple();
|
|
919
|
+
|
|
920
|
+
await sql`
|
|
921
|
+
CREATE TABLE IF NOT EXISTS ai.memories (
|
|
922
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
923
|
+
short_id TEXT NOT NULL,
|
|
924
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
925
|
+
kind TEXT NOT NULL,
|
|
926
|
+
content TEXT NOT NULL,
|
|
927
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
928
|
+
source TEXT NOT NULL DEFAULT 'user',
|
|
929
|
+
source_conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
|
|
930
|
+
source_message_id UUID REFERENCES ai.messages(id) ON DELETE SET NULL,
|
|
931
|
+
resource_type TEXT,
|
|
932
|
+
resource_id TEXT,
|
|
933
|
+
superseded_by_id UUID REFERENCES ai.memories(id) ON DELETE SET NULL,
|
|
934
|
+
deleted_at TIMESTAMPTZ,
|
|
935
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
936
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
937
|
+
search_document TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', COALESCE(content, ''))) STORED,
|
|
938
|
+
CONSTRAINT ai_memories_kind_check CHECK (kind IN ('fact', 'preference', 'workflow')),
|
|
939
|
+
CONSTRAINT ai_memories_priority_check CHECK (priority IN ('normal', 'pinned')),
|
|
940
|
+
CONSTRAINT ai_memories_source_check CHECK (source IN ('user', 'agent', 'background')),
|
|
941
|
+
CONSTRAINT ai_memories_content_check CHECK (char_length(content) BETWEEN 1 AND 500),
|
|
942
|
+
CONSTRAINT ai_memories_resource_value_check CHECK (
|
|
943
|
+
(resource_type IS NULL AND resource_id IS NULL)
|
|
944
|
+
OR (char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512)
|
|
945
|
+
),
|
|
946
|
+
CONSTRAINT ai_memories_workflow_resource_check CHECK (
|
|
947
|
+
(kind = 'workflow' AND resource_type IS NOT NULL AND resource_id IS NOT NULL)
|
|
948
|
+
OR (kind <> 'workflow' AND resource_type IS NULL AND resource_id IS NULL)
|
|
949
|
+
)
|
|
950
|
+
)
|
|
951
|
+
`.simple();
|
|
952
|
+
|
|
953
|
+
await sql`ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
954
|
+
await sql`ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS resource_type TEXT`.simple();
|
|
955
|
+
await sql`ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS resource_id TEXT`.simple();
|
|
956
|
+
await sql`ALTER TABLE ai.memories DROP CONSTRAINT IF EXISTS ai_memories_resource_value_check`.simple();
|
|
957
|
+
await sql`
|
|
958
|
+
ALTER TABLE ai.memories ADD CONSTRAINT ai_memories_resource_value_check CHECK (
|
|
959
|
+
(resource_type IS NULL AND resource_id IS NULL)
|
|
960
|
+
OR (char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512)
|
|
961
|
+
)
|
|
962
|
+
`.simple();
|
|
963
|
+
await sql`ALTER TABLE ai.memories DROP CONSTRAINT IF EXISTS ai_memories_kind_check`.simple();
|
|
964
|
+
await sql`ALTER TABLE ai.memories ADD CONSTRAINT ai_memories_kind_check CHECK (kind IN ('fact', 'preference', 'workflow'))`.simple();
|
|
965
|
+
await sql`ALTER TABLE ai.memories DROP CONSTRAINT IF EXISTS ai_memories_workflow_resource_check`.simple();
|
|
966
|
+
await sql`
|
|
967
|
+
ALTER TABLE ai.memories ADD CONSTRAINT ai_memories_workflow_resource_check CHECK (
|
|
968
|
+
(kind = 'workflow' AND resource_type IS NOT NULL AND resource_id IS NOT NULL)
|
|
969
|
+
OR (kind <> 'workflow' AND resource_type IS NULL AND resource_id IS NULL)
|
|
970
|
+
)
|
|
971
|
+
`.simple();
|
|
972
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_short_id ON ai.memories(short_id)`.simple();
|
|
973
|
+
await backfillAiShortIds(
|
|
974
|
+
"idx_ai_memories_short_id",
|
|
975
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.memories WHERE short_id IS NULL`,
|
|
976
|
+
(id, shortId) => sql`UPDATE ai.memories SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
977
|
+
);
|
|
978
|
+
await sql`ALTER TABLE ai.memories ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
979
|
+
|
|
980
|
+
await sql`
|
|
981
|
+
CREATE INDEX IF NOT EXISTS idx_ai_memories_user_active
|
|
982
|
+
ON ai.memories(user_id, priority DESC, updated_at DESC, id)
|
|
983
|
+
WHERE deleted_at IS NULL AND superseded_by_id IS NULL
|
|
984
|
+
`.simple();
|
|
985
|
+
await sql`
|
|
986
|
+
CREATE INDEX IF NOT EXISTS idx_ai_memories_search_document
|
|
987
|
+
ON ai.memories USING GIN(search_document)
|
|
988
|
+
`.simple();
|
|
989
|
+
await sql`
|
|
990
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_user_content_active
|
|
991
|
+
ON ai.memories(user_id, lower(content))
|
|
992
|
+
WHERE deleted_at IS NULL AND superseded_by_id IS NULL
|
|
993
|
+
`.simple();
|
|
994
|
+
|
|
995
|
+
// pg_textsearch only improves ranking. Native GIN FTS above remains the
|
|
996
|
+
// correctness baseline and optional setup must never block AI startup.
|
|
997
|
+
try {
|
|
998
|
+
const [extension] = await sql<{ available: boolean; installed: boolean; server_version: number }[]>`
|
|
999
|
+
SELECT
|
|
1000
|
+
EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_textsearch') AS available,
|
|
1001
|
+
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed,
|
|
1002
|
+
current_setting('server_version_num')::int AS server_version
|
|
1003
|
+
`;
|
|
1004
|
+
let installed = extension?.installed ?? false;
|
|
1005
|
+
if (!installed && extension?.available && extension.server_version >= 170000) {
|
|
1006
|
+
await sql`CREATE EXTENSION IF NOT EXISTS pg_textsearch`.simple();
|
|
1007
|
+
installed = true;
|
|
1008
|
+
}
|
|
1009
|
+
if (installed) {
|
|
1010
|
+
await sql
|
|
1011
|
+
.unsafe(`
|
|
1012
|
+
CREATE INDEX IF NOT EXISTS memories_search_bm25_idx
|
|
1013
|
+
ON ai.memories USING bm25 ((content)) WITH (text_config='simple')
|
|
1014
|
+
`)
|
|
1015
|
+
.simple();
|
|
1016
|
+
console.log(" ✓ ai.memories optional BM25 search index");
|
|
1017
|
+
}
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
console.warn(" ! ai.memories optional BM25 search index unavailable; native PostgreSQL FTS remains active", error);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Turn-level learning avoids replaying an entire conversation whenever an
|
|
1023
|
+
// Assistant or tool message updates the chat. Existing turns are marked as
|
|
1024
|
+
// already considered exactly once when the column is introduced.
|
|
1025
|
+
await sql`
|
|
1026
|
+
DO $$
|
|
1027
|
+
BEGIN
|
|
1028
|
+
IF NOT EXISTS (
|
|
1029
|
+
SELECT 1 FROM information_schema.columns
|
|
1030
|
+
WHERE table_schema = 'ai' AND table_name = 'turns' AND column_name = 'memory_learned_at'
|
|
1031
|
+
) THEN
|
|
1032
|
+
ALTER TABLE ai.turns ADD COLUMN memory_learned_at TIMESTAMPTZ;
|
|
1033
|
+
UPDATE ai.turns SET memory_learned_at = COALESCE(completed_at, created_at)
|
|
1034
|
+
WHERE status IN ('completed', 'failed', 'aborted');
|
|
1035
|
+
END IF;
|
|
1036
|
+
END $$
|
|
1037
|
+
`.simple();
|
|
1038
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS memory_learn_failed_at TIMESTAMPTZ`.simple();
|
|
1039
|
+
await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS memory_learn_fail_count INTEGER NOT NULL DEFAULT 0`.simple();
|
|
1040
|
+
await sql`
|
|
1041
|
+
CREATE INDEX IF NOT EXISTS idx_ai_turns_memory_learning_candidates
|
|
1042
|
+
ON ai.turns(completed_at, id)
|
|
1043
|
+
WHERE status = 'completed' AND memory_learned_at IS NULL
|
|
1044
|
+
`.simple();
|
|
1045
|
+
|
|
1046
|
+
await sql`
|
|
1047
|
+
CREATE TABLE IF NOT EXISTS ai.memory_workflow_evidence (
|
|
1048
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
1049
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
1050
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
1051
|
+
capability_id TEXT NOT NULL,
|
|
1052
|
+
resource_type TEXT NOT NULL,
|
|
1053
|
+
resource_id TEXT NOT NULL,
|
|
1054
|
+
resource_title TEXT,
|
|
1055
|
+
observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1056
|
+
reviewed_at TIMESTAMPTZ,
|
|
1057
|
+
PRIMARY KEY (turn_id, capability_id, resource_type, resource_id),
|
|
1058
|
+
CONSTRAINT ai_memory_workflow_evidence_capability_check CHECK (char_length(capability_id) BETWEEN 3 AND 180),
|
|
1059
|
+
CONSTRAINT ai_memory_workflow_evidence_resource_check CHECK (
|
|
1060
|
+
char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512
|
|
1061
|
+
)
|
|
1062
|
+
)
|
|
1063
|
+
`.simple();
|
|
1064
|
+
await sql`ALTER TABLE ai.memory_workflow_evidence DROP CONSTRAINT IF EXISTS ai_memory_workflow_evidence_capability_check`.simple();
|
|
1065
|
+
await sql`
|
|
1066
|
+
ALTER TABLE ai.memory_workflow_evidence ADD CONSTRAINT ai_memory_workflow_evidence_capability_check
|
|
1067
|
+
CHECK (char_length(capability_id) BETWEEN 3 AND 180)
|
|
1068
|
+
`.simple();
|
|
1069
|
+
await sql`ALTER TABLE ai.memory_workflow_evidence DROP CONSTRAINT IF EXISTS ai_memory_workflow_evidence_resource_check`.simple();
|
|
1070
|
+
await sql`
|
|
1071
|
+
ALTER TABLE ai.memory_workflow_evidence ADD CONSTRAINT ai_memory_workflow_evidence_resource_check
|
|
1072
|
+
CHECK (char_length(resource_type) BETWEEN 3 AND 180 AND char_length(resource_id) BETWEEN 1 AND 512)
|
|
1073
|
+
`.simple();
|
|
1074
|
+
await sql`
|
|
1075
|
+
CREATE INDEX IF NOT EXISTS idx_ai_memory_workflow_evidence_pending
|
|
1076
|
+
ON ai.memory_workflow_evidence(user_id, capability_id, resource_type, resource_id, observed_at)
|
|
1077
|
+
WHERE reviewed_at IS NULL
|
|
1078
|
+
`.simple();
|
|
1079
|
+
|
|
1080
|
+
await sql`
|
|
1081
|
+
CREATE TABLE IF NOT EXISTS ai.memory_learning_runs (
|
|
1082
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1083
|
+
short_id TEXT NOT NULL,
|
|
1084
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
1085
|
+
conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
|
|
1086
|
+
turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
|
|
1087
|
+
conversation_title TEXT NOT NULL,
|
|
1088
|
+
run_kind TEXT NOT NULL DEFAULT 'turn',
|
|
1089
|
+
evidence_key TEXT,
|
|
1090
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
1091
|
+
model_profile_id TEXT,
|
|
1092
|
+
duration_ms INTEGER,
|
|
1093
|
+
input_tokens INTEGER,
|
|
1094
|
+
output_tokens INTEGER,
|
|
1095
|
+
accounted_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1096
|
+
added_count INTEGER NOT NULL DEFAULT 0,
|
|
1097
|
+
updated_count INTEGER NOT NULL DEFAULT 0,
|
|
1098
|
+
merged_count INTEGER NOT NULL DEFAULT 0,
|
|
1099
|
+
retired_count INTEGER NOT NULL DEFAULT 0,
|
|
1100
|
+
changes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
1101
|
+
error TEXT,
|
|
1102
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1103
|
+
completed_at TIMESTAMPTZ,
|
|
1104
|
+
CONSTRAINT ai_memory_learning_runs_status_check CHECK (status IN ('running', 'ok', 'skipped', 'failed')),
|
|
1105
|
+
CONSTRAINT ai_memory_learning_runs_kind_check CHECK (run_kind IN ('turn', 'workflow')),
|
|
1106
|
+
CONSTRAINT ai_memory_learning_runs_evidence_check CHECK (
|
|
1107
|
+
(run_kind = 'turn' AND evidence_key IS NULL)
|
|
1108
|
+
OR (run_kind = 'workflow' AND turn_id IS NOT NULL AND evidence_key IS NOT NULL)
|
|
1109
|
+
),
|
|
1110
|
+
CONSTRAINT ai_memory_learning_runs_changes_check CHECK (jsonb_typeof(changes) = 'array')
|
|
1111
|
+
)
|
|
1112
|
+
`.simple();
|
|
1113
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL`.simple();
|
|
1114
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS run_kind TEXT NOT NULL DEFAULT 'turn'`.simple();
|
|
1115
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS evidence_key TEXT`.simple();
|
|
1116
|
+
await sql`
|
|
1117
|
+
UPDATE ai.memory_learning_runs
|
|
1118
|
+
SET evidence_key = 'legacy:' || id::text
|
|
1119
|
+
WHERE run_kind = 'workflow' AND evidence_key IS NULL
|
|
1120
|
+
`.simple();
|
|
1121
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS input_tokens INTEGER`.simple();
|
|
1122
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS output_tokens INTEGER`.simple();
|
|
1123
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS accounted_tokens INTEGER NOT NULL DEFAULT 0`.simple();
|
|
1124
|
+
await sql`ALTER TABLE ai.memory_learning_runs ADD COLUMN IF NOT EXISTS retired_count INTEGER NOT NULL DEFAULT 0`.simple();
|
|
1125
|
+
await sql`ALTER TABLE ai.memory_learning_runs DROP CONSTRAINT IF EXISTS ai_memory_learning_runs_kind_check`.simple();
|
|
1126
|
+
await sql`
|
|
1127
|
+
ALTER TABLE ai.memory_learning_runs ADD CONSTRAINT ai_memory_learning_runs_kind_check
|
|
1128
|
+
CHECK (run_kind IN ('turn', 'workflow'))
|
|
1129
|
+
`.simple();
|
|
1130
|
+
await sql`ALTER TABLE ai.memory_learning_runs DROP CONSTRAINT IF EXISTS ai_memory_learning_runs_evidence_check`.simple();
|
|
1131
|
+
await sql`
|
|
1132
|
+
ALTER TABLE ai.memory_learning_runs ADD CONSTRAINT ai_memory_learning_runs_evidence_check CHECK (
|
|
1133
|
+
(run_kind = 'turn' AND evidence_key IS NULL)
|
|
1134
|
+
OR (run_kind = 'workflow' AND turn_id IS NOT NULL AND evidence_key IS NOT NULL)
|
|
1135
|
+
)
|
|
1136
|
+
`.simple();
|
|
1137
|
+
await sql`
|
|
1138
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_short_id
|
|
1139
|
+
ON ai.memory_learning_runs(short_id)
|
|
1140
|
+
`.simple();
|
|
1141
|
+
await sql`
|
|
1142
|
+
CREATE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_user_created
|
|
1143
|
+
ON ai.memory_learning_runs(user_id, created_at DESC, id DESC)
|
|
1144
|
+
`.simple();
|
|
1145
|
+
await sql`
|
|
1146
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_turn
|
|
1147
|
+
ON ai.memory_learning_runs(turn_id)
|
|
1148
|
+
WHERE turn_id IS NOT NULL AND run_kind = 'turn'
|
|
1149
|
+
`.simple();
|
|
1150
|
+
await sql`
|
|
1151
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memory_learning_runs_workflow_evidence
|
|
1152
|
+
ON ai.memory_learning_runs(evidence_key)
|
|
1153
|
+
WHERE evidence_key IS NOT NULL AND run_kind = 'workflow'
|
|
1154
|
+
`.simple();
|
|
1155
|
+
|
|
1156
|
+
// ── Conversation file workspace ───────────────────────────────────
|
|
1157
|
+
await sql`
|
|
1158
|
+
CREATE TABLE IF NOT EXISTS ai.files (
|
|
1159
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1160
|
+
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
|
1161
|
+
path TEXT NOT NULL,
|
|
1162
|
+
bytes BYTEA NOT NULL,
|
|
1163
|
+
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
1164
|
+
size INTEGER NOT NULL,
|
|
1165
|
+
origin TEXT NOT NULL DEFAULT 'assistant' CONSTRAINT ai_files_origin_check CHECK (origin IN ('user', 'assistant')),
|
|
1166
|
+
version BIGINT NOT NULL DEFAULT 1,
|
|
1167
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1168
|
+
CONSTRAINT ai_files_path_unique UNIQUE (conversation_id, path)
|
|
1169
|
+
)
|
|
1170
|
+
`.simple();
|
|
1171
|
+
await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT 'assistant'`.simple();
|
|
1172
|
+
await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1`.simple();
|
|
1173
|
+
await sql`UPDATE ai.files SET origin = 'user' WHERE (path = '/input' OR path LIKE '/input/%') AND origin <> 'user'`.simple();
|
|
1174
|
+
await sql`
|
|
1175
|
+
DO $$ BEGIN
|
|
1176
|
+
IF NOT EXISTS (
|
|
1177
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'ai_files_origin_check' AND conrelid = 'ai.files'::regclass
|
|
1178
|
+
) THEN
|
|
1179
|
+
ALTER TABLE ai.files ADD CONSTRAINT ai_files_origin_check CHECK (origin IN ('user', 'assistant'));
|
|
1180
|
+
END IF;
|
|
1181
|
+
END $$
|
|
1182
|
+
`.simple();
|
|
1183
|
+
|
|
1184
|
+
// Inline image parts were an alpha transport detail. Move their bytes into
|
|
1185
|
+
// the authorized conversation file store and leave only stable references in
|
|
1186
|
+
// message history. Re-running is idempotent because the path is derived from
|
|
1187
|
+
// the message short id and content position.
|
|
1188
|
+
await sql`
|
|
1189
|
+
WITH inline_images AS (
|
|
1190
|
+
SELECT
|
|
1191
|
+
m.id AS message_id,
|
|
1192
|
+
m.conversation_id,
|
|
1193
|
+
part.ordinality,
|
|
1194
|
+
part.value,
|
|
1195
|
+
lower(part.value->>'mediaType') AS media_type,
|
|
1196
|
+
decode(part.value->>'data', 'base64') AS bytes,
|
|
1197
|
+
'/image-' || m.short_id || '-' || part.ordinality ||
|
|
1198
|
+
CASE lower(part.value->>'mediaType')
|
|
1199
|
+
WHEN 'image/jpeg' THEN '.jpg'
|
|
1200
|
+
WHEN 'image/jpg' THEN '.jpg'
|
|
1201
|
+
WHEN 'image/png' THEN '.png'
|
|
1202
|
+
WHEN 'image/webp' THEN '.webp'
|
|
1203
|
+
WHEN 'image/gif' THEN '.gif'
|
|
1204
|
+
END AS path
|
|
1205
|
+
FROM ai.messages m
|
|
1206
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
1207
|
+
(CASE WHEN jsonb_typeof(m.message) = 'string' THEN (m.message #>> '{}')::jsonb ELSE m.message END)->'content'
|
|
1208
|
+
) WITH ORDINALITY AS part(value, ordinality)
|
|
1209
|
+
WHERE m.role = 'user'
|
|
1210
|
+
AND part.value->>'type' = 'file'
|
|
1211
|
+
AND lower(part.value->>'mediaType') IN ('image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif')
|
|
1212
|
+
AND part.value->>'data' ~ '^[A-Za-z0-9+/]*={0,2}$'
|
|
1213
|
+
AND length(part.value->>'data') % 4 = 0
|
|
1214
|
+
), migrated AS (
|
|
1215
|
+
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin)
|
|
1216
|
+
SELECT conversation_id, path, bytes, media_type, octet_length(bytes), 'user'
|
|
1217
|
+
FROM inline_images
|
|
1218
|
+
ON CONFLICT (conversation_id, path) DO UPDATE SET updated_at = ai.files.updated_at
|
|
1219
|
+
WHERE ai.files.bytes = EXCLUDED.bytes
|
|
1220
|
+
AND ai.files.media_type = EXCLUDED.media_type
|
|
1221
|
+
AND ai.files.size = EXCLUDED.size
|
|
1222
|
+
AND ai.files.origin = 'user'
|
|
1223
|
+
RETURNING conversation_id, path
|
|
1224
|
+
)
|
|
1225
|
+
UPDATE ai.messages m
|
|
1226
|
+
SET message = CASE
|
|
1227
|
+
WHEN jsonb_typeof(m.message) = 'string' THEN to_jsonb((jsonb_set(
|
|
1228
|
+
(m.message #>> '{}')::jsonb,
|
|
1229
|
+
'{content}',
|
|
1230
|
+
(
|
|
1231
|
+
SELECT jsonb_agg(
|
|
1232
|
+
CASE
|
|
1233
|
+
WHEN image.path IS NULL THEN part.value
|
|
1234
|
+
ELSE jsonb_build_object(
|
|
1235
|
+
'type', 'text',
|
|
1236
|
+
'text', '<attachment path="' || image.path || '" media-type="' || image.media_type || '" size="' || octet_length(image.bytes) || '" />'
|
|
1237
|
+
)
|
|
1238
|
+
END
|
|
1239
|
+
ORDER BY part.ordinality
|
|
1240
|
+
)
|
|
1241
|
+
FROM jsonb_array_elements((m.message #>> '{}')::jsonb->'content') WITH ORDINALITY AS part(value, ordinality)
|
|
1242
|
+
LEFT JOIN inline_images image ON image.message_id = m.id AND image.ordinality = part.ordinality
|
|
1243
|
+
)
|
|
1244
|
+
))::text)
|
|
1245
|
+
ELSE jsonb_set(
|
|
1246
|
+
m.message,
|
|
1247
|
+
'{content}',
|
|
1248
|
+
(
|
|
1249
|
+
SELECT jsonb_agg(
|
|
1250
|
+
CASE
|
|
1251
|
+
WHEN image.path IS NULL THEN part.value
|
|
1252
|
+
ELSE jsonb_build_object(
|
|
1253
|
+
'type', 'text',
|
|
1254
|
+
'text', '<attachment path="' || image.path || '" media-type="' || image.media_type || '" size="' || octet_length(image.bytes) || '" />'
|
|
1255
|
+
)
|
|
1256
|
+
END
|
|
1257
|
+
ORDER BY part.ordinality
|
|
1258
|
+
)
|
|
1259
|
+
FROM jsonb_array_elements(m.message->'content') WITH ORDINALITY AS part(value, ordinality)
|
|
1260
|
+
LEFT JOIN inline_images image ON image.message_id = m.id AND image.ordinality = part.ordinality
|
|
1261
|
+
)
|
|
1262
|
+
)
|
|
1263
|
+
END
|
|
1264
|
+
WHERE EXISTS (SELECT 1 FROM inline_images image WHERE image.message_id = m.id)
|
|
1265
|
+
AND NOT EXISTS (
|
|
1266
|
+
SELECT 1 FROM inline_images image
|
|
1267
|
+
WHERE image.message_id = m.id
|
|
1268
|
+
AND NOT EXISTS (
|
|
1269
|
+
SELECT 1 FROM migrated
|
|
1270
|
+
WHERE migrated.conversation_id = image.conversation_id AND migrated.path = image.path
|
|
1271
|
+
)
|
|
1272
|
+
)
|
|
1273
|
+
`.simple();
|
|
1274
|
+
await sql`
|
|
1275
|
+
DO $$ BEGIN
|
|
1276
|
+
IF EXISTS (
|
|
1277
|
+
SELECT 1
|
|
1278
|
+
FROM ai.messages m
|
|
1279
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
1280
|
+
(CASE WHEN jsonb_typeof(m.message) = 'string' THEN (m.message #>> '{}')::jsonb ELSE m.message END)->'content'
|
|
1281
|
+
) AS part(value)
|
|
1282
|
+
WHERE m.role = 'user'
|
|
1283
|
+
AND part.value->>'type' = 'file'
|
|
1284
|
+
AND lower(part.value->>'mediaType') IN ('image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif')
|
|
1285
|
+
AND part.value->>'data' IS NOT NULL
|
|
1286
|
+
) THEN
|
|
1287
|
+
RAISE EXCEPTION 'Could not migrate every historical inline AI image without a file collision';
|
|
1288
|
+
END IF;
|
|
1289
|
+
END $$
|
|
1290
|
+
`.simple();
|
|
1291
|
+
await sql`
|
|
1292
|
+
WITH referenced_parts AS (
|
|
1293
|
+
SELECT m.loop_id::uuid AS turn_id, part.ordinality, part.value
|
|
1294
|
+
FROM ai.messages m
|
|
1295
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
1296
|
+
(CASE WHEN jsonb_typeof(m.message) = 'string' THEN (m.message #>> '{}')::jsonb ELSE m.message END)->'content'
|
|
1297
|
+
) WITH ORDINALITY AS part(value, ordinality)
|
|
1298
|
+
WHERE m.role = 'user'
|
|
1299
|
+
AND m.loop_id IS NOT NULL
|
|
1300
|
+
AND part.value->>'type' = 'text'
|
|
1301
|
+
AND part.value->>'text' LIKE '<attachment path=%'
|
|
1302
|
+
), rewritten AS (
|
|
1303
|
+
SELECT
|
|
1304
|
+
t.id,
|
|
1305
|
+
jsonb_set(
|
|
1306
|
+
config.value,
|
|
1307
|
+
'{input}',
|
|
1308
|
+
jsonb_agg(
|
|
1309
|
+
CASE WHEN part.value->>'type' = 'file' AND reference.value IS NOT NULL THEN reference.value ELSE part.value END
|
|
1310
|
+
ORDER BY part.ordinality
|
|
1311
|
+
)
|
|
1312
|
+
) AS value,
|
|
1313
|
+
jsonb_typeof(t.run_config) = 'string' AS string_encoded
|
|
1314
|
+
FROM ai.turns t
|
|
1315
|
+
CROSS JOIN LATERAL (
|
|
1316
|
+
SELECT CASE WHEN jsonb_typeof(t.run_config) = 'string' THEN (t.run_config #>> '{}')::jsonb ELSE t.run_config END AS value
|
|
1317
|
+
) AS config
|
|
1318
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
1319
|
+
CASE WHEN jsonb_typeof(config.value->'input') = 'array' THEN config.value->'input' ELSE '[]'::jsonb END
|
|
1320
|
+
) WITH ORDINALITY AS part(value, ordinality)
|
|
1321
|
+
LEFT JOIN referenced_parts reference ON reference.turn_id = t.id AND reference.ordinality = part.ordinality
|
|
1322
|
+
WHERE t.run_config IS NOT NULL
|
|
1323
|
+
AND t.status NOT IN ('queued', 'running', 'waiting_for_action')
|
|
1324
|
+
GROUP BY t.id, t.run_config, config.value
|
|
1325
|
+
HAVING bool_or(part.value->>'type' = 'file' AND reference.value IS NOT NULL)
|
|
1326
|
+
)
|
|
1327
|
+
UPDATE ai.turns t
|
|
1328
|
+
SET run_config = CASE WHEN rewritten.string_encoded THEN to_jsonb(rewritten.value::text) ELSE rewritten.value END
|
|
1329
|
+
FROM rewritten
|
|
1330
|
+
WHERE rewritten.id = t.id
|
|
1331
|
+
`.simple();
|
|
1332
|
+
// EXTERNAL keeps bytea un-compressed in TOAST so substring() slices read
|
|
1333
|
+
// only the needed chunks — head/tail on big files must not load everything.
|
|
1334
|
+
await sql`ALTER TABLE ai.files ALTER COLUMN bytes SET STORAGE EXTERNAL`.simple();
|
|
1335
|
+
await sql`
|
|
1336
|
+
CREATE TABLE IF NOT EXISTS ai.turn_files (
|
|
1337
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
1338
|
+
path TEXT NOT NULL,
|
|
1339
|
+
bytes BYTEA NOT NULL,
|
|
1340
|
+
media_type TEXT NOT NULL,
|
|
1341
|
+
size INTEGER NOT NULL,
|
|
1342
|
+
origin TEXT NOT NULL CONSTRAINT ai_turn_files_origin_check CHECK (origin IN ('user', 'assistant')),
|
|
1343
|
+
updated_at TIMESTAMPTZ NOT NULL,
|
|
1344
|
+
version BIGINT NOT NULL,
|
|
1345
|
+
PRIMARY KEY (turn_id, path)
|
|
1346
|
+
)
|
|
1347
|
+
`.simple();
|
|
1348
|
+
await sql`ALTER TABLE ai.turn_files ADD COLUMN IF NOT EXISTS version BIGINT NOT NULL DEFAULT 1`.simple();
|
|
1349
|
+
await sql`ALTER TABLE ai.turn_files ALTER COLUMN bytes SET STORAGE EXTERNAL`.simple();
|
|
1350
|
+
|
|
1351
|
+
// Skills were previously alpha-only and used personal/workspace ownership.
|
|
1352
|
+
// Make the one-time model cut to permission-owned Agent Skills, then keep the
|
|
1353
|
+
// new schema intact on later migrations.
|
|
1354
|
+
await sql`
|
|
1355
|
+
DO $$
|
|
1356
|
+
BEGIN
|
|
1357
|
+
IF EXISTS (
|
|
1358
|
+
SELECT 1 FROM information_schema.columns
|
|
1359
|
+
WHERE table_schema = 'ai' AND table_name = 'skills' AND column_name IN ('scope', 'owner_user_id')
|
|
1360
|
+
) THEN
|
|
1361
|
+
IF to_regclass('ai.skill_access') IS NOT NULL THEN
|
|
1362
|
+
DELETE FROM auth.access WHERE id IN (SELECT access_id FROM ai.skill_access);
|
|
1363
|
+
END IF;
|
|
1364
|
+
DROP TABLE IF EXISTS ai.skill_seeds CASCADE;
|
|
1365
|
+
DROP TABLE IF EXISTS ai.skill_access CASCADE;
|
|
1366
|
+
DROP TABLE IF EXISTS ai.skill_user_state CASCADE;
|
|
1367
|
+
DROP TABLE IF EXISTS ai.skill_files CASCADE;
|
|
1368
|
+
DROP TABLE IF EXISTS ai.skill_events CASCADE;
|
|
1369
|
+
DROP TABLE IF EXISTS ai.skills CASCADE;
|
|
1370
|
+
END IF;
|
|
1371
|
+
END $$
|
|
1372
|
+
`.simple();
|
|
1373
|
+
await sql`DROP TABLE IF EXISTS ai.skill_user_state CASCADE`.simple();
|
|
1374
|
+
await sql`DROP TABLE IF EXISTS ai.skill_files CASCADE`.simple();
|
|
1375
|
+
await sql`DROP TABLE IF EXISTS ai.skill_events CASCADE`.simple();
|
|
1376
|
+
|
|
1377
|
+
await sql`
|
|
1378
|
+
CREATE TABLE IF NOT EXISTS ai.skills (
|
|
1379
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1380
|
+
short_id TEXT NOT NULL,
|
|
1381
|
+
name TEXT NOT NULL,
|
|
1382
|
+
description TEXT NOT NULL,
|
|
1383
|
+
instructions TEXT NOT NULL,
|
|
1384
|
+
extra_frontmatter JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
1385
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
1386
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1387
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1388
|
+
CONSTRAINT ai_skills_name_check CHECK (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(name) <= 64),
|
|
1389
|
+
CONSTRAINT ai_skills_description_check CHECK (length(btrim(description)) BETWEEN 1 AND 1024),
|
|
1390
|
+
CONSTRAINT ai_skills_instructions_check CHECK (length(btrim(instructions)) BETWEEN 1 AND 100000),
|
|
1391
|
+
CONSTRAINT ai_skills_extra_frontmatter_check CHECK (jsonb_typeof(extra_frontmatter) = 'object')
|
|
1392
|
+
)
|
|
1393
|
+
`.simple();
|
|
1394
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_short_id ON ai.skills(short_id)`.simple();
|
|
1395
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
|
|
1396
|
+
await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
|
|
1397
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_managed_key ON ai.skills(managed_key) WHERE managed_key IS NOT NULL`.simple();
|
|
1398
|
+
await sql`
|
|
1399
|
+
CREATE TABLE IF NOT EXISTS ai.skill_seeds (
|
|
1400
|
+
key TEXT PRIMARY KEY,
|
|
1401
|
+
seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
1402
|
+
)
|
|
1403
|
+
`.simple();
|
|
1404
|
+
await backfillAiShortIds(
|
|
1405
|
+
"idx_ai_skills_short_id",
|
|
1406
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
|
|
1407
|
+
(id, shortId) => sql`UPDATE ai.skills SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
1408
|
+
);
|
|
1409
|
+
await sql`ALTER TABLE ai.skills ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1410
|
+
|
|
1411
|
+
await sql`
|
|
1412
|
+
CREATE TABLE IF NOT EXISTS ai.skill_access (
|
|
1413
|
+
skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
|
|
1414
|
+
access_id UUID NOT NULL REFERENCES auth.access(id) ON DELETE CASCADE,
|
|
1415
|
+
short_id TEXT NOT NULL,
|
|
1416
|
+
PRIMARY KEY (skill_id, access_id)
|
|
1417
|
+
)
|
|
1418
|
+
`.simple();
|
|
1419
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skill_access_short_id ON ai.skill_access(skill_id, short_id)`.simple();
|
|
1420
|
+
for (const row of await sql<
|
|
1421
|
+
{ skill_id: string; access_id: string }[]
|
|
1422
|
+
>`SELECT skill_id, access_id FROM ai.skill_access WHERE short_id IS NULL`) {
|
|
1423
|
+
await withAiShortId(
|
|
1424
|
+
"idx_ai_skill_access_short_id",
|
|
1425
|
+
(shortId) => sql`
|
|
1426
|
+
UPDATE ai.skill_access SET short_id = ${shortId}
|
|
1427
|
+
WHERE skill_id = ${row.skill_id}::uuid AND access_id = ${row.access_id}::uuid
|
|
1428
|
+
`,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
await sql`ALTER TABLE ai.skill_access ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1432
|
+
|
|
1433
|
+
await sql`
|
|
1434
|
+
CREATE TABLE IF NOT EXISTS ai.skill_user_disabled (
|
|
1435
|
+
skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
|
|
1436
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
1437
|
+
disabled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1438
|
+
PRIMARY KEY (skill_id, user_id)
|
|
1439
|
+
)
|
|
1440
|
+
`.simple();
|
|
1441
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_ai_skill_user_disabled_user ON ai.skill_user_disabled(user_id)`.simple();
|
|
1442
|
+
|
|
1443
|
+
await sql`
|
|
1444
|
+
CREATE TABLE IF NOT EXISTS ai.skill_references (
|
|
1445
|
+
skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
|
|
1446
|
+
path TEXT NOT NULL,
|
|
1447
|
+
content TEXT NOT NULL,
|
|
1448
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1449
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1450
|
+
PRIMARY KEY (skill_id, path),
|
|
1451
|
+
CONSTRAINT ai_skill_references_path_check CHECK (
|
|
1452
|
+
path ~ '^references/[a-z0-9][a-z0-9._-]*\\.md$' AND length(path) <= 200
|
|
1453
|
+
),
|
|
1454
|
+
CONSTRAINT ai_skill_references_content_check CHECK (length(content) <= 100000)
|
|
1455
|
+
)
|
|
1456
|
+
`.simple();
|
|
1457
|
+
|
|
1458
|
+
await sql`
|
|
1459
|
+
CREATE TABLE IF NOT EXISTS ai.turn_skill_snapshots (
|
|
1460
|
+
turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
|
|
1461
|
+
skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
|
|
1462
|
+
skill_name TEXT NOT NULL,
|
|
1463
|
+
description TEXT NOT NULL,
|
|
1464
|
+
revision INTEGER NOT NULL,
|
|
1465
|
+
instructions TEXT NOT NULL,
|
|
1466
|
+
files JSONB NOT NULL,
|
|
1467
|
+
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1468
|
+
PRIMARY KEY (turn_id, skill_id),
|
|
1469
|
+
UNIQUE (turn_id, skill_name),
|
|
1470
|
+
CONSTRAINT ai_turn_skill_snapshots_files_check CHECK (jsonb_typeof(files) = 'array')
|
|
1471
|
+
)
|
|
1472
|
+
`.simple();
|
|
1473
|
+
|
|
1474
|
+
await sql`
|
|
1475
|
+
CREATE TABLE IF NOT EXISTS ai.projects (
|
|
1476
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1477
|
+
short_id TEXT NOT NULL,
|
|
1478
|
+
name TEXT NOT NULL,
|
|
1479
|
+
description TEXT NOT NULL DEFAULT '',
|
|
1480
|
+
icon TEXT NOT NULL DEFAULT 'ti ti-folders',
|
|
1481
|
+
instructions TEXT NOT NULL DEFAULT '',
|
|
1482
|
+
default_model_profile_id TEXT,
|
|
1483
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
1484
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1485
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1486
|
+
CONSTRAINT ai_projects_name_check CHECK (length(btrim(name)) BETWEEN 1 AND 120),
|
|
1487
|
+
CONSTRAINT ai_projects_description_check CHECK (length(description) <= 500),
|
|
1488
|
+
CONSTRAINT ai_projects_instructions_check CHECK (length(instructions) <= 16000)
|
|
1489
|
+
)
|
|
1490
|
+
`.simple();
|
|
1491
|
+
|
|
1492
|
+
// Projects were alpha-only while ownership was represented by a user FK.
|
|
1493
|
+
// Make the hard cut before normalizing any legacy rows: keep personal chats,
|
|
1494
|
+
// discard local alpha Projects, then build only the final access-owned schema.
|
|
1495
|
+
await sql`
|
|
1496
|
+
DO $$
|
|
1497
|
+
BEGIN
|
|
1498
|
+
IF EXISTS (
|
|
1499
|
+
SELECT 1 FROM information_schema.columns
|
|
1500
|
+
WHERE table_schema = 'ai' AND table_name = 'projects' AND column_name = 'owner_user_id'
|
|
1501
|
+
) THEN
|
|
1502
|
+
IF to_regclass('ai.project_access') IS NOT NULL THEN
|
|
1503
|
+
DELETE FROM auth.access
|
|
1504
|
+
WHERE id IN (SELECT access_id FROM ai.project_access);
|
|
1505
|
+
END IF;
|
|
1506
|
+
DELETE FROM ai.projects;
|
|
1507
|
+
DROP INDEX IF EXISTS ai.idx_ai_projects_owner_name;
|
|
1508
|
+
ALTER TABLE ai.projects DROP COLUMN owner_user_id;
|
|
1509
|
+
END IF;
|
|
1510
|
+
END $$
|
|
1511
|
+
`.simple();
|
|
1512
|
+
|
|
1513
|
+
await sql`ALTER TABLE ai.projects ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1514
|
+
await sql`
|
|
1515
|
+
DO $$
|
|
1516
|
+
BEGIN
|
|
1517
|
+
IF EXISTS (
|
|
1518
|
+
SELECT 1 FROM pg_constraint
|
|
1519
|
+
WHERE conrelid = 'ai.projects'::regclass AND conname = 'ai_projects_id_app_id_key'
|
|
1520
|
+
) THEN
|
|
1521
|
+
ALTER TABLE ai.projects DROP CONSTRAINT ai_projects_id_app_id_key;
|
|
1522
|
+
END IF;
|
|
1523
|
+
IF EXISTS (
|
|
1524
|
+
SELECT 1 FROM information_schema.columns
|
|
1525
|
+
WHERE table_schema = 'ai' AND table_name = 'projects' AND column_name = 'app_id'
|
|
1526
|
+
) THEN
|
|
1527
|
+
ALTER TABLE ai.projects DROP COLUMN app_id;
|
|
1528
|
+
END IF;
|
|
1529
|
+
END $$
|
|
1530
|
+
`.simple();
|
|
1531
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_projects_short_id ON ai.projects(short_id)`.simple();
|
|
1532
|
+
await backfillAiShortIds(
|
|
1533
|
+
"idx_ai_projects_short_id",
|
|
1534
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.projects WHERE short_id IS NULL`,
|
|
1535
|
+
(id, shortId) => sql`UPDATE ai.projects SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
1536
|
+
);
|
|
1537
|
+
await sql`ALTER TABLE ai.projects ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1538
|
+
await sql`
|
|
1539
|
+
CREATE TABLE IF NOT EXISTS ai.project_access (
|
|
1540
|
+
project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
|
|
1541
|
+
access_id UUID NOT NULL REFERENCES auth.access(id) ON DELETE CASCADE,
|
|
1542
|
+
short_id TEXT NOT NULL,
|
|
1543
|
+
PRIMARY KEY (project_id, access_id)
|
|
1544
|
+
)
|
|
1545
|
+
`.simple();
|
|
1546
|
+
await sql`ALTER TABLE ai.project_access ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1547
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_access_short_id ON ai.project_access(project_id, short_id)`.simple();
|
|
1548
|
+
for (const row of await sql<
|
|
1549
|
+
{ project_id: string; access_id: string }[]
|
|
1550
|
+
>`SELECT project_id, access_id FROM ai.project_access WHERE short_id IS NULL`) {
|
|
1551
|
+
await withAiShortId(
|
|
1552
|
+
"idx_ai_project_access_short_id",
|
|
1553
|
+
(shortId) => sql`
|
|
1554
|
+
UPDATE ai.project_access SET short_id = ${shortId}
|
|
1555
|
+
WHERE project_id = ${row.project_id}::uuid AND access_id = ${row.access_id}::uuid
|
|
1556
|
+
`,
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
await sql`ALTER TABLE ai.project_access ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1560
|
+
|
|
1561
|
+
await sql`
|
|
1562
|
+
CREATE TABLE IF NOT EXISTS ai.project_knowledge (
|
|
1563
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1564
|
+
short_id TEXT NOT NULL,
|
|
1565
|
+
project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
|
|
1566
|
+
title TEXT NOT NULL,
|
|
1567
|
+
content TEXT NOT NULL,
|
|
1568
|
+
search_document TSVECTOR GENERATED ALWAYS AS (
|
|
1569
|
+
setweight(to_tsvector('simple', title), 'A') || setweight(to_tsvector('simple', content), 'B')
|
|
1570
|
+
) STORED,
|
|
1571
|
+
created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
1572
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1573
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1574
|
+
CONSTRAINT ai_project_knowledge_title_check CHECK (length(btrim(title)) BETWEEN 1 AND 200),
|
|
1575
|
+
CONSTRAINT ai_project_knowledge_content_check CHECK (length(btrim(content)) BETWEEN 1 AND 100000)
|
|
1576
|
+
)
|
|
1577
|
+
`.simple();
|
|
1578
|
+
await sql`ALTER TABLE ai.project_knowledge ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1579
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_knowledge_short_id ON ai.project_knowledge(project_id, short_id)`.simple();
|
|
1580
|
+
await backfillAiShortIds(
|
|
1581
|
+
"idx_ai_project_knowledge_short_id",
|
|
1582
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.project_knowledge WHERE short_id IS NULL`,
|
|
1583
|
+
(id, shortId) => sql`UPDATE ai.project_knowledge SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
1584
|
+
);
|
|
1585
|
+
await sql`ALTER TABLE ai.project_knowledge ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1586
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_ai_project_knowledge_search ON ai.project_knowledge USING GIN(search_document)`.simple();
|
|
1587
|
+
|
|
1588
|
+
await sql`
|
|
1589
|
+
CREATE TABLE IF NOT EXISTS ai.project_files (
|
|
1590
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1591
|
+
short_id TEXT NOT NULL,
|
|
1592
|
+
project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
|
|
1593
|
+
path TEXT NOT NULL,
|
|
1594
|
+
media_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
1595
|
+
bytes BYTEA NOT NULL,
|
|
1596
|
+
size INTEGER NOT NULL,
|
|
1597
|
+
created_by_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
1598
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1599
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1600
|
+
UNIQUE (project_id, path),
|
|
1601
|
+
CONSTRAINT ai_project_files_size_check CHECK (size BETWEEN 0 AND 10485760)
|
|
1602
|
+
)
|
|
1603
|
+
`.simple();
|
|
1604
|
+
await sql`ALTER TABLE ai.project_files ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1605
|
+
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_files_short_id ON ai.project_files(project_id, short_id)`.simple();
|
|
1606
|
+
await backfillAiShortIds(
|
|
1607
|
+
"idx_ai_project_files_short_id",
|
|
1608
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.project_files WHERE short_id IS NULL`,
|
|
1609
|
+
(id, shortId) => sql`UPDATE ai.project_files SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
1610
|
+
);
|
|
1611
|
+
await sql`ALTER TABLE ai.project_files ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1612
|
+
await sql`ALTER TABLE ai.project_files ALTER COLUMN bytes SET STORAGE EXTERNAL`.simple();
|
|
1613
|
+
|
|
1614
|
+
await sql`DROP TABLE IF EXISTS ai.project_references CASCADE`.simple();
|
|
1615
|
+
await sql`
|
|
1616
|
+
CREATE TABLE IF NOT EXISTS ai.project_resource_refs (
|
|
1617
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1618
|
+
short_id TEXT NOT NULL,
|
|
1619
|
+
project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
|
|
1620
|
+
resource_type TEXT NOT NULL,
|
|
1621
|
+
resource_id TEXT NOT NULL,
|
|
1622
|
+
label TEXT NOT NULL DEFAULT '',
|
|
1623
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1624
|
+
UNIQUE (project_id, resource_type, resource_id)
|
|
1625
|
+
)
|
|
1626
|
+
`.simple();
|
|
1627
|
+
await sql`ALTER TABLE ai.project_resource_refs ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
|
|
1628
|
+
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
|
+
await backfillAiShortIds(
|
|
1630
|
+
"idx_ai_project_resource_refs_short_id",
|
|
1631
|
+
await sql<{ id: string }[]>`SELECT id FROM ai.project_resource_refs WHERE short_id IS NULL`,
|
|
1632
|
+
(id, shortId) => sql`UPDATE ai.project_resource_refs SET short_id = ${shortId} WHERE id = ${id}::uuid`,
|
|
1633
|
+
);
|
|
1634
|
+
await sql`ALTER TABLE ai.project_resource_refs ALTER COLUMN short_id SET NOT NULL`.simple();
|
|
1635
|
+
|
|
1636
|
+
await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES ai.projects(id) ON DELETE SET NULL`.simple();
|
|
1637
|
+
await sql`
|
|
1638
|
+
CREATE INDEX IF NOT EXISTS idx_ai_conversations_project_owner_updated
|
|
1639
|
+
ON ai.conversations(project_id, created_by_user_id, updated_at DESC)
|
|
1640
|
+
WHERE project_id IS NOT NULL AND archived_at IS NULL
|
|
1641
|
+
`.simple();
|
|
1642
|
+
|
|
1643
|
+
// Durable Realtime UI invalidations. Triggers live at the persistence seam so
|
|
1644
|
+
// route, worker, tool, enrichment, and scheduler writes cannot bypass them.
|
|
1645
|
+
// The outbox row commits or rolls back with the authoritative domain write;
|
|
1646
|
+
// publishing remains an after-commit runtime responsibility.
|
|
1647
|
+
await sql`
|
|
1648
|
+
CREATE TABLE IF NOT EXISTS ai.live_invalidation_outbox (
|
|
1649
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1650
|
+
change_id UUID NOT NULL,
|
|
1651
|
+
audience_user_id UUID NOT NULL,
|
|
1652
|
+
conversation_short_id TEXT,
|
|
1653
|
+
project_short_id TEXT,
|
|
1654
|
+
domains TEXT[] NOT NULL,
|
|
1655
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1656
|
+
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1657
|
+
claimed_until TIMESTAMPTZ,
|
|
1658
|
+
delivered_at TIMESTAMPTZ,
|
|
1659
|
+
dead_at TIMESTAMPTZ,
|
|
1660
|
+
last_error TEXT,
|
|
1661
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
1662
|
+
CONSTRAINT ai_live_invalidation_domains_check CHECK (
|
|
1663
|
+
cardinality(domains) > 0 AND domains <@ ARRAY[
|
|
1664
|
+
'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
|
|
1665
|
+
'project-list', 'project-detail', 'project-context'
|
|
1666
|
+
]::text[]
|
|
1667
|
+
)
|
|
1668
|
+
)
|
|
1669
|
+
`.simple();
|
|
1670
|
+
await sql`
|
|
1671
|
+
DO $$
|
|
1672
|
+
BEGIN
|
|
1673
|
+
IF EXISTS (
|
|
1674
|
+
SELECT 1 FROM information_schema.columns
|
|
1675
|
+
WHERE table_schema = 'ai' AND table_name = 'live_invalidation_outbox' AND column_name = 'app_id'
|
|
1676
|
+
) THEN
|
|
1677
|
+
DELETE FROM ai.live_invalidation_outbox;
|
|
1678
|
+
ALTER TABLE ai.live_invalidation_outbox DROP COLUMN app_id;
|
|
1679
|
+
END IF;
|
|
1680
|
+
END $$
|
|
1681
|
+
`.simple();
|
|
1682
|
+
// Audience IDs are immutable event-routing data. A foreign key makes an
|
|
1683
|
+
// unrelated user-delete cascade invoke Project triggers after the user row
|
|
1684
|
+
// has disappeared, aborting the authoritative delete instead of merely
|
|
1685
|
+
// leaving a harmless undeliverable invalidation.
|
|
1686
|
+
await sql`
|
|
1687
|
+
ALTER TABLE ai.live_invalidation_outbox
|
|
1688
|
+
DROP CONSTRAINT IF EXISTS live_invalidation_outbox_audience_user_id_fkey
|
|
1689
|
+
`.simple();
|
|
1690
|
+
await sql`
|
|
1691
|
+
CREATE INDEX IF NOT EXISTS idx_ai_live_invalidation_outbox_pending
|
|
1692
|
+
ON ai.live_invalidation_outbox(next_attempt_at, created_at, id)
|
|
1693
|
+
WHERE delivered_at IS NULL AND dead_at IS NULL
|
|
1694
|
+
`.simple();
|
|
1695
|
+
await sql`
|
|
1696
|
+
CREATE INDEX IF NOT EXISTS idx_ai_live_invalidation_outbox_user_order
|
|
1697
|
+
ON ai.live_invalidation_outbox(audience_user_id, created_at, id)
|
|
1698
|
+
WHERE delivered_at IS NULL AND dead_at IS NULL
|
|
1699
|
+
`.simple();
|
|
1700
|
+
|
|
1701
|
+
// Hard cut: the former app-scoped signature is a distinct PostgreSQL
|
|
1702
|
+
// overload, so CREATE OR REPLACE cannot remove it for us.
|
|
1703
|
+
await sql`
|
|
1704
|
+
DROP FUNCTION IF EXISTS ai.enqueue_live_for_user(UUID, TEXT, UUID, TEXT, TEXT, TEXT[])
|
|
1705
|
+
`.simple();
|
|
1706
|
+
|
|
1707
|
+
await sql`
|
|
1708
|
+
CREATE OR REPLACE FUNCTION ai.enqueue_live_for_user(
|
|
1709
|
+
p_change_id UUID,
|
|
1710
|
+
p_user_id UUID,
|
|
1711
|
+
p_conversation_short_id TEXT,
|
|
1712
|
+
p_project_short_id TEXT,
|
|
1713
|
+
p_domains TEXT[]
|
|
1714
|
+
) RETURNS void AS $$
|
|
1715
|
+
BEGIN
|
|
1716
|
+
IF p_user_id IS NULL OR cardinality(p_domains) = 0 THEN RETURN; END IF;
|
|
1717
|
+
INSERT INTO ai.live_invalidation_outbox (
|
|
1718
|
+
change_id, audience_user_id, conversation_short_id, project_short_id, domains
|
|
1719
|
+
) VALUES (
|
|
1720
|
+
p_change_id, p_user_id, p_conversation_short_id, p_project_short_id,
|
|
1721
|
+
ARRAY(SELECT DISTINCT domain FROM unnest(p_domains) domain ORDER BY domain)
|
|
1722
|
+
);
|
|
1723
|
+
END
|
|
1724
|
+
$$ LANGUAGE plpgsql
|
|
1725
|
+
`.simple();
|
|
1726
|
+
|
|
1727
|
+
await sql`
|
|
1728
|
+
CREATE OR REPLACE FUNCTION ai.enqueue_live_for_conversation(
|
|
1729
|
+
p_conversation_id UUID,
|
|
1730
|
+
p_domains TEXT[]
|
|
1731
|
+
) RETURNS void AS $$
|
|
1732
|
+
DECLARE
|
|
1733
|
+
target RECORD;
|
|
1734
|
+
BEGIN
|
|
1735
|
+
SELECT conversation.created_by_user_id, conversation.short_id,
|
|
1736
|
+
project.short_id AS project_short_id
|
|
1737
|
+
INTO target
|
|
1738
|
+
FROM ai.conversations conversation
|
|
1739
|
+
LEFT JOIN ai.projects project ON project.id = conversation.project_id
|
|
1740
|
+
WHERE conversation.id = p_conversation_id;
|
|
1741
|
+
IF NOT FOUND THEN RETURN; END IF;
|
|
1742
|
+
PERFORM ai.enqueue_live_for_user(
|
|
1743
|
+
gen_random_uuid(), target.created_by_user_id, target.short_id, target.project_short_id, p_domains
|
|
1744
|
+
);
|
|
1745
|
+
END
|
|
1746
|
+
$$ LANGUAGE plpgsql
|
|
1747
|
+
`.simple();
|
|
1748
|
+
|
|
1749
|
+
await sql`
|
|
1750
|
+
CREATE OR REPLACE FUNCTION ai.enqueue_live_for_project(
|
|
1751
|
+
p_project_id UUID,
|
|
1752
|
+
p_domains TEXT[]
|
|
1753
|
+
) RETURNS void AS $$
|
|
1754
|
+
DECLARE
|
|
1755
|
+
change UUID := gen_random_uuid();
|
|
1756
|
+
project_short TEXT;
|
|
1757
|
+
recipient RECORD;
|
|
1758
|
+
BEGIN
|
|
1759
|
+
SELECT short_id INTO project_short FROM ai.projects WHERE id = p_project_id;
|
|
1760
|
+
IF NOT FOUND THEN RETURN; END IF;
|
|
1761
|
+
|
|
1762
|
+
FOR recipient IN
|
|
1763
|
+
WITH RECURSIVE
|
|
1764
|
+
access_rows AS (
|
|
1765
|
+
SELECT access.*
|
|
1766
|
+
FROM ai.project_access project_access
|
|
1767
|
+
JOIN auth.access access ON access.id = project_access.access_id
|
|
1768
|
+
WHERE project_access.project_id = p_project_id
|
|
1769
|
+
AND access.permission <> 'none'
|
|
1770
|
+
),
|
|
1771
|
+
root_groups(group_id, group_ids) AS (
|
|
1772
|
+
SELECT group_id, ARRAY[group_id]::uuid[]
|
|
1773
|
+
FROM access_rows
|
|
1774
|
+
WHERE group_id IS NOT NULL
|
|
1775
|
+
UNION ALL
|
|
1776
|
+
SELECT nested.child_group_id, root.group_ids || nested.child_group_id
|
|
1777
|
+
FROM auth.group_groups_v2 nested
|
|
1778
|
+
JOIN root_groups root ON root.group_id = nested.parent_group_id
|
|
1779
|
+
WHERE NOT nested.child_group_id = ANY(root.group_ids)
|
|
1780
|
+
),
|
|
1781
|
+
recipients(user_id) AS (
|
|
1782
|
+
SELECT user_id FROM access_rows WHERE user_id IS NOT NULL
|
|
1783
|
+
UNION
|
|
1784
|
+
SELECT membership.user_id
|
|
1785
|
+
FROM root_groups root
|
|
1786
|
+
JOIN auth.user_groups_v2 membership ON membership.group_id = root.group_id
|
|
1787
|
+
UNION
|
|
1788
|
+
SELECT users.id
|
|
1789
|
+
FROM auth.users users
|
|
1790
|
+
WHERE EXISTS (
|
|
1791
|
+
SELECT 1 FROM access_rows
|
|
1792
|
+
WHERE authenticated_only OR (
|
|
1793
|
+
user_id IS NULL AND group_id IS NULL AND service_account_id IS NULL AND NOT authenticated_only
|
|
1794
|
+
)
|
|
1795
|
+
)
|
|
1796
|
+
)
|
|
1797
|
+
SELECT DISTINCT user_id FROM recipients WHERE user_id IS NOT NULL
|
|
1798
|
+
LOOP
|
|
1799
|
+
PERFORM ai.enqueue_live_for_user(change, recipient.user_id, NULL, project_short, p_domains);
|
|
1800
|
+
END LOOP;
|
|
1801
|
+
END
|
|
1802
|
+
$$ LANGUAGE plpgsql
|
|
1803
|
+
`.simple();
|
|
1804
|
+
|
|
1805
|
+
await sql`
|
|
1806
|
+
CREATE OR REPLACE FUNCTION ai.live_conversation_row_changed() RETURNS trigger AS $$
|
|
1807
|
+
DECLARE
|
|
1808
|
+
row_data RECORD;
|
|
1809
|
+
project_short TEXT;
|
|
1810
|
+
BEGIN
|
|
1811
|
+
IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
|
|
1812
|
+
SELECT short_id INTO project_short FROM ai.projects WHERE id = row_data.project_id;
|
|
1813
|
+
PERFORM ai.enqueue_live_for_user(
|
|
1814
|
+
gen_random_uuid(), row_data.created_by_user_id, row_data.short_id, project_short,
|
|
1815
|
+
ARRAY['conversation-list', 'conversation-detail']::text[]
|
|
1816
|
+
);
|
|
1817
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1818
|
+
END
|
|
1819
|
+
$$ LANGUAGE plpgsql
|
|
1820
|
+
`.simple();
|
|
1821
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_conversations_changed ON ai.conversations`.simple();
|
|
1822
|
+
await sql`
|
|
1823
|
+
CREATE TRIGGER ai_live_conversations_changed
|
|
1824
|
+
AFTER INSERT OR UPDATE OR DELETE ON ai.conversations
|
|
1825
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_row_changed()
|
|
1826
|
+
`.simple();
|
|
1827
|
+
|
|
1828
|
+
await sql`
|
|
1829
|
+
CREATE OR REPLACE FUNCTION ai.live_conversation_child_changed() RETURNS trigger AS $$
|
|
1830
|
+
DECLARE
|
|
1831
|
+
row_data RECORD;
|
|
1832
|
+
conversation UUID;
|
|
1833
|
+
BEGIN
|
|
1834
|
+
IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
|
|
1835
|
+
IF TG_TABLE_NAME = 'chat_task_occurrences' THEN
|
|
1836
|
+
SELECT conversation_id INTO conversation FROM ai.chat_tasks WHERE id = row_data.task_id;
|
|
1837
|
+
ELSE
|
|
1838
|
+
conversation := row_data.conversation_id;
|
|
1839
|
+
END IF;
|
|
1840
|
+
IF conversation IS NOT NULL THEN
|
|
1841
|
+
PERFORM ai.enqueue_live_for_conversation(conversation, string_to_array(TG_ARGV[0], ','));
|
|
1842
|
+
END IF;
|
|
1843
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1844
|
+
END
|
|
1845
|
+
$$ LANGUAGE plpgsql
|
|
1846
|
+
`.simple();
|
|
1847
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_conversation_sources_changed ON ai.conversation_sources`.simple();
|
|
1848
|
+
await sql`
|
|
1849
|
+
CREATE TRIGGER ai_live_conversation_sources_changed AFTER INSERT OR UPDATE OR DELETE ON ai.conversation_sources
|
|
1850
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-sources')
|
|
1851
|
+
`.simple();
|
|
1852
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_conversation_resources_changed ON ai.conversation_resource_refs`.simple();
|
|
1853
|
+
await sql`
|
|
1854
|
+
CREATE TRIGGER ai_live_conversation_resources_changed AFTER INSERT OR UPDATE OR DELETE ON ai.conversation_resource_refs
|
|
1855
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-sources')
|
|
1856
|
+
`.simple();
|
|
1857
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_conversation_files_changed ON ai.files`.simple();
|
|
1858
|
+
await sql`
|
|
1859
|
+
CREATE TRIGGER ai_live_conversation_files_changed AFTER INSERT OR UPDATE OR DELETE ON ai.files
|
|
1860
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-files,conversation-sources')
|
|
1861
|
+
`.simple();
|
|
1862
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_turns_changed ON ai.turns`.simple();
|
|
1863
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_turns_status_changed ON ai.turns`.simple();
|
|
1864
|
+
await sql`
|
|
1865
|
+
CREATE TRIGGER ai_live_turns_changed
|
|
1866
|
+
AFTER INSERT OR DELETE ON ai.turns
|
|
1867
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
|
|
1868
|
+
`.simple();
|
|
1869
|
+
await sql`
|
|
1870
|
+
CREATE TRIGGER ai_live_turns_status_changed
|
|
1871
|
+
AFTER UPDATE OF status, error, completed_at, cancel_requested_at ON ai.turns
|
|
1872
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list,conversation-detail')
|
|
1873
|
+
`.simple();
|
|
1874
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_chat_tasks_changed ON ai.chat_tasks`.simple();
|
|
1875
|
+
await sql`
|
|
1876
|
+
CREATE TRIGGER ai_live_chat_tasks_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_tasks
|
|
1877
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-tasks')
|
|
1878
|
+
`.simple();
|
|
1879
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_chat_task_occurrences_changed ON ai.chat_task_occurrences`.simple();
|
|
1880
|
+
await sql`
|
|
1881
|
+
CREATE TRIGGER ai_live_chat_task_occurrences_changed AFTER INSERT OR UPDATE OR DELETE ON ai.chat_task_occurrences
|
|
1882
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-tasks')
|
|
1883
|
+
`.simple();
|
|
1884
|
+
|
|
1885
|
+
await sql`
|
|
1886
|
+
CREATE OR REPLACE FUNCTION ai.live_project_row_changed() RETURNS trigger AS $$
|
|
1887
|
+
DECLARE row_data RECORD;
|
|
1888
|
+
BEGIN
|
|
1889
|
+
IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
|
|
1890
|
+
PERFORM ai.enqueue_live_for_project(
|
|
1891
|
+
row_data.id,
|
|
1892
|
+
ARRAY['project-list', 'project-detail', 'project-context', 'conversation-list']::text[]
|
|
1893
|
+
);
|
|
1894
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1895
|
+
END
|
|
1896
|
+
$$ LANGUAGE plpgsql
|
|
1897
|
+
`.simple();
|
|
1898
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_projects_changed ON ai.projects`.simple();
|
|
1899
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_projects_deleted ON ai.projects`.simple();
|
|
1900
|
+
await sql`
|
|
1901
|
+
CREATE TRIGGER ai_live_projects_changed
|
|
1902
|
+
AFTER INSERT OR UPDATE ON ai.projects
|
|
1903
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_row_changed()
|
|
1904
|
+
`.simple();
|
|
1905
|
+
await sql`
|
|
1906
|
+
CREATE TRIGGER ai_live_projects_deleted
|
|
1907
|
+
BEFORE DELETE ON ai.projects
|
|
1908
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_row_changed()
|
|
1909
|
+
`.simple();
|
|
1910
|
+
|
|
1911
|
+
await sql`
|
|
1912
|
+
CREATE OR REPLACE FUNCTION ai.live_project_child_changed() RETURNS trigger AS $$
|
|
1913
|
+
DECLARE row_data RECORD;
|
|
1914
|
+
BEGIN
|
|
1915
|
+
IF TG_OP = 'DELETE' THEN row_data := OLD; ELSE row_data := NEW; END IF;
|
|
1916
|
+
PERFORM ai.enqueue_live_for_project(row_data.project_id, string_to_array(TG_ARGV[0], ','));
|
|
1917
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1918
|
+
END
|
|
1919
|
+
$$ LANGUAGE plpgsql
|
|
1920
|
+
`.simple();
|
|
1921
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_knowledge_changed ON ai.project_knowledge`.simple();
|
|
1922
|
+
await sql`
|
|
1923
|
+
CREATE TRIGGER ai_live_project_knowledge_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_knowledge
|
|
1924
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
|
|
1925
|
+
`.simple();
|
|
1926
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_files_changed ON ai.project_files`.simple();
|
|
1927
|
+
await sql`
|
|
1928
|
+
CREATE TRIGGER ai_live_project_files_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_files
|
|
1929
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
|
|
1930
|
+
`.simple();
|
|
1931
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_resource_refs_changed ON ai.project_resource_refs`.simple();
|
|
1932
|
+
await sql`
|
|
1933
|
+
CREATE TRIGGER ai_live_project_resource_refs_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_resource_refs
|
|
1934
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
|
|
1935
|
+
`.simple();
|
|
1936
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_access_added ON ai.project_access`.simple();
|
|
1937
|
+
await sql`
|
|
1938
|
+
CREATE TRIGGER ai_live_project_access_added AFTER INSERT OR UPDATE ON ai.project_access
|
|
1939
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-list,project-detail,project-context,conversation-list')
|
|
1940
|
+
`.simple();
|
|
1941
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_access_removed ON ai.project_access`.simple();
|
|
1942
|
+
await sql`
|
|
1943
|
+
CREATE TRIGGER ai_live_project_access_removed BEFORE DELETE ON ai.project_access
|
|
1944
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-list,project-detail,project-context,conversation-list')
|
|
1945
|
+
`.simple();
|
|
1946
|
+
await sql`
|
|
1947
|
+
CREATE OR REPLACE FUNCTION ai.live_project_access_permission_changed() RETURNS trigger AS $$
|
|
1948
|
+
DECLARE
|
|
1949
|
+
project UUID;
|
|
1950
|
+
changed_access UUID;
|
|
1951
|
+
BEGIN
|
|
1952
|
+
changed_access := CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END;
|
|
1953
|
+
FOR project IN SELECT project_id FROM ai.project_access WHERE access_id = changed_access
|
|
1954
|
+
LOOP
|
|
1955
|
+
PERFORM ai.enqueue_live_for_project(
|
|
1956
|
+
project,
|
|
1957
|
+
ARRAY['project-list', 'project-detail', 'project-context', 'conversation-list']::text[]
|
|
1958
|
+
);
|
|
1959
|
+
END LOOP;
|
|
1960
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
1961
|
+
END
|
|
1962
|
+
$$ LANGUAGE plpgsql
|
|
1963
|
+
`.simple();
|
|
1964
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_access_permission_changed ON auth.access`.simple();
|
|
1965
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_access_permission_changing ON auth.access`.simple();
|
|
1966
|
+
await sql`
|
|
1967
|
+
CREATE TRIGGER ai_live_project_access_permission_changing
|
|
1968
|
+
BEFORE UPDATE OF permission ON auth.access
|
|
1969
|
+
FOR EACH ROW
|
|
1970
|
+
WHEN (OLD.permission IS DISTINCT FROM NEW.permission)
|
|
1971
|
+
EXECUTE FUNCTION ai.live_project_access_permission_changed()
|
|
1972
|
+
`.simple();
|
|
1973
|
+
await sql`
|
|
1974
|
+
CREATE TRIGGER ai_live_project_access_permission_changed
|
|
1975
|
+
AFTER UPDATE OF permission ON auth.access
|
|
1976
|
+
FOR EACH ROW
|
|
1977
|
+
WHEN (OLD.permission IS DISTINCT FROM NEW.permission)
|
|
1978
|
+
EXECUTE FUNCTION ai.live_project_access_permission_changed()
|
|
1979
|
+
`.simple();
|
|
1980
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_access_deleting ON auth.access`.simple();
|
|
1981
|
+
await sql`
|
|
1982
|
+
CREATE TRIGGER ai_live_project_access_deleting
|
|
1983
|
+
BEFORE DELETE ON auth.access
|
|
1984
|
+
FOR EACH ROW
|
|
1985
|
+
EXECUTE FUNCTION ai.live_project_access_permission_changed()
|
|
1986
|
+
`.simple();
|
|
1987
|
+
|
|
1988
|
+
await sql`
|
|
1989
|
+
CREATE OR REPLACE FUNCTION ai.live_project_group_membership_changed() RETURNS trigger AS $$
|
|
1990
|
+
DECLARE
|
|
1991
|
+
changed_group UUID;
|
|
1992
|
+
project UUID;
|
|
1993
|
+
BEGIN
|
|
1994
|
+
IF TG_TABLE_NAME = 'user_groups_v2' THEN
|
|
1995
|
+
changed_group := CASE WHEN TG_OP = 'DELETE' THEN OLD.group_id ELSE NEW.group_id END;
|
|
1996
|
+
ELSE
|
|
1997
|
+
changed_group := CASE WHEN TG_OP = 'DELETE' THEN OLD.parent_group_id ELSE NEW.parent_group_id END;
|
|
1998
|
+
END IF;
|
|
1999
|
+
FOR project IN
|
|
2000
|
+
WITH RECURSIVE ancestors(group_id, seen) AS (
|
|
2001
|
+
SELECT changed_group, ARRAY[changed_group]::uuid[]
|
|
2002
|
+
UNION ALL
|
|
2003
|
+
SELECT relation.parent_group_id, ancestors.seen || relation.parent_group_id
|
|
2004
|
+
FROM auth.group_groups_v2 relation
|
|
2005
|
+
JOIN ancestors ON relation.child_group_id = ancestors.group_id
|
|
2006
|
+
WHERE NOT relation.parent_group_id = ANY(ancestors.seen)
|
|
2007
|
+
)
|
|
2008
|
+
SELECT DISTINCT project_access.project_id
|
|
2009
|
+
FROM ancestors
|
|
2010
|
+
JOIN auth.access access ON access.group_id = ancestors.group_id AND access.permission <> 'none'
|
|
2011
|
+
JOIN ai.project_access project_access ON project_access.access_id = access.id
|
|
2012
|
+
LOOP
|
|
2013
|
+
PERFORM ai.enqueue_live_for_project(
|
|
2014
|
+
project,
|
|
2015
|
+
ARRAY['project-list', 'project-detail', 'project-context', 'conversation-list']::text[]
|
|
2016
|
+
);
|
|
2017
|
+
END LOOP;
|
|
2018
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
2019
|
+
END
|
|
2020
|
+
$$ LANGUAGE plpgsql
|
|
2021
|
+
`.simple();
|
|
2022
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_user_group_added ON auth.user_groups_v2`.simple();
|
|
2023
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_user_group_removed ON auth.user_groups_v2`.simple();
|
|
2024
|
+
await sql`
|
|
2025
|
+
CREATE TRIGGER ai_live_project_user_group_added AFTER INSERT ON auth.user_groups_v2
|
|
2026
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
|
|
2027
|
+
`.simple();
|
|
2028
|
+
await sql`
|
|
2029
|
+
CREATE TRIGGER ai_live_project_user_group_removed BEFORE DELETE ON auth.user_groups_v2
|
|
2030
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
|
|
2031
|
+
`.simple();
|
|
2032
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_group_group_added ON auth.group_groups_v2`.simple();
|
|
2033
|
+
await sql`DROP TRIGGER IF EXISTS ai_live_project_group_group_removed ON auth.group_groups_v2`.simple();
|
|
2034
|
+
await sql`
|
|
2035
|
+
CREATE TRIGGER ai_live_project_group_group_added AFTER INSERT ON auth.group_groups_v2
|
|
2036
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
|
|
2037
|
+
`.simple();
|
|
2038
|
+
await sql`
|
|
2039
|
+
CREATE TRIGGER ai_live_project_group_group_removed BEFORE DELETE ON auth.group_groups_v2
|
|
2040
|
+
FOR EACH ROW EXECUTE FUNCTION ai.live_project_group_membership_changed()
|
|
2041
|
+
`.simple();
|
|
2042
|
+
|
|
2043
|
+
// Provider API keys used to live inside the ai.model_profiles_json setting.
|
|
2044
|
+
// A JSON setting is delivered to the admin UI in full, so every key rode
|
|
2045
|
+
// along in the page payload; a per-profile row keeps them server-side and
|
|
2046
|
+
// lets a deployment hold as many as it has profiles.
|
|
2047
|
+
await sql`
|
|
2048
|
+
CREATE TABLE IF NOT EXISTS ai.model_credentials (
|
|
2049
|
+
profile_id TEXT PRIMARY KEY,
|
|
2050
|
+
secret TEXT NOT NULL,
|
|
2051
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2052
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
2053
|
+
)
|
|
2054
|
+
`.simple();
|
|
2055
|
+
|
|
2056
|
+
// One-time move of keys that were stored inside the profiles setting. Once
|
|
2057
|
+
// the blob holds no apiKey the split yields nothing and this is a no-op, so
|
|
2058
|
+
// it is safe to re-run. Dynamic imports keep the module free of store deps.
|
|
2059
|
+
{
|
|
2060
|
+
const { coreSettings } = await import("../services");
|
|
2061
|
+
const { setAiCredential, splitAiProfileCredentials } = await import("./credentials");
|
|
2062
|
+
const split = splitAiProfileCredentials((await coreSettings.get<string>("ai.model_profiles_json")) ?? "[]");
|
|
2063
|
+
if (split && split.credentials.length > 0) {
|
|
2064
|
+
for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret);
|
|
2065
|
+
await coreSettings.set("ai.model_profiles_json", split.profilesJson);
|
|
2066
|
+
console.log(` ✓ moved ${split.credentials.length} AI provider key(s) out of ai.model_profiles_json`);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
await sql`ALTER TABLE ai.structured_runs
|
|
2071
|
+
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
2072
|
+
ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
|
|
2073
|
+
ADD COLUMN IF NOT EXISTS turn_id UUID REFERENCES ai.turns(id) ON DELETE SET NULL,
|
|
2074
|
+
ADD COLUMN IF NOT EXISTS workflow_run_id UUID,
|
|
2075
|
+
ADD COLUMN IF NOT EXISTS trace_id TEXT`.simple();
|
|
2076
|
+
await sql`CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_user_created ON ai.structured_runs(user_id, created_at DESC)`.simple();
|
|
2077
|
+
|
|
2078
|
+
await migrateAiTurnUsage();
|
|
2079
|
+
await migrateAiModelAccess();
|
|
2080
|
+
|
|
2081
|
+
console.log(" ✓ ai conversation tables");
|
|
2082
|
+
};
|