@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
package/src/ai/store.ts
ADDED
|
@@ -0,0 +1,3113 @@
|
|
|
1
|
+
import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
|
|
2
|
+
import type { Usage } from "@k2b/nessi/ai";
|
|
3
|
+
import { sql } from "bun";
|
|
4
|
+
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
|
+
import { logger } from "../services/logging";
|
|
6
|
+
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import type { AiTurnBlock } from "./protocol";
|
|
8
|
+
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
9
|
+
import type {
|
|
10
|
+
AiConversation,
|
|
11
|
+
AiConversationDraft,
|
|
12
|
+
AiConversationPage,
|
|
13
|
+
AiConversationResourceOccurrence,
|
|
14
|
+
AiConversationResourceRef,
|
|
15
|
+
AiConversationService,
|
|
16
|
+
AiConversationSource,
|
|
17
|
+
AiConversationTimelineEntry,
|
|
18
|
+
AiEnrichmentOverview,
|
|
19
|
+
AiEnrichmentOverviewRun,
|
|
20
|
+
AiEnrichmentRun,
|
|
21
|
+
AiFrontendToolMode,
|
|
22
|
+
AiInterChatMessage,
|
|
23
|
+
AiPendingTurnAction,
|
|
24
|
+
AiPendingTurnActionRecord,
|
|
25
|
+
AiStoredMessage,
|
|
26
|
+
AiToolPresentation,
|
|
27
|
+
AiTurn,
|
|
28
|
+
AiTurnClaim,
|
|
29
|
+
AiTurnRunConfig,
|
|
30
|
+
AiTurnStatus,
|
|
31
|
+
AiTurnSteer,
|
|
32
|
+
AiTurnSweepResult,
|
|
33
|
+
} from "./types";
|
|
34
|
+
|
|
35
|
+
/** A queued turn this old without a claim is considered lost and re-enqueued by the sweep. */
|
|
36
|
+
const SWEEP_STALE_QUEUED_MS = 15_000;
|
|
37
|
+
/** A queued turn this old that never started is failed outright. */
|
|
38
|
+
const SWEEP_DEAD_QUEUED_MS = 30 * 60_000;
|
|
39
|
+
const SEARCH_TEXT_MAX_CHARS = 50_000;
|
|
40
|
+
const CONVERSATION_BM25_INDEX = "ai.conversations_search_bm25_idx";
|
|
41
|
+
const MESSAGE_BM25_INDEX = "ai.messages_search_bm25_idx";
|
|
42
|
+
const SEARCH_CAPABILITY_ERROR_CODES = new Set(["0A000", "42704", "42883", "55000"]);
|
|
43
|
+
const log = logger("ai:conversation-search");
|
|
44
|
+
|
|
45
|
+
export const resolveAiTurnShortIds = async (input: {
|
|
46
|
+
conversationId: string;
|
|
47
|
+
turnIds: readonly string[];
|
|
48
|
+
}): Promise<Map<string, string>> => {
|
|
49
|
+
const turnIds = [...new Set(input.turnIds.filter(Boolean))];
|
|
50
|
+
if (turnIds.length === 0) return new Map();
|
|
51
|
+
const rows = await sql<{ id: string; short_id: string }[]>`
|
|
52
|
+
SELECT id::text AS id, short_id
|
|
53
|
+
FROM ai.turns
|
|
54
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
55
|
+
AND id::text = ANY(${toPgTextArray(turnIds)}::text[])
|
|
56
|
+
`;
|
|
57
|
+
return new Map(rows.map((row) => [row.id, row.short_id]));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type ConversationSearchBackend = "native" | "bm25";
|
|
61
|
+
let conversationSearchBackendPromise: Promise<ConversationSearchBackend> | null = null;
|
|
62
|
+
|
|
63
|
+
const detectConversationSearchBackend = async (): Promise<ConversationSearchBackend> => {
|
|
64
|
+
const [row] = await sql<{ available: boolean }[]>`
|
|
65
|
+
SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch')
|
|
66
|
+
AND EXISTS (
|
|
67
|
+
SELECT 1 FROM pg_class c JOIN pg_am am ON am.oid = c.relam
|
|
68
|
+
WHERE c.oid = to_regclass(${CONVERSATION_BM25_INDEX}) AND am.amname = 'bm25'
|
|
69
|
+
)
|
|
70
|
+
AND EXISTS (
|
|
71
|
+
SELECT 1 FROM pg_class c JOIN pg_am am ON am.oid = c.relam
|
|
72
|
+
WHERE c.oid = to_regclass(${MESSAGE_BM25_INDEX}) AND am.amname = 'bm25'
|
|
73
|
+
) AS available
|
|
74
|
+
`;
|
|
75
|
+
return row?.available ? "bm25" : "native";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getConversationSearchBackend = (): Promise<ConversationSearchBackend> => {
|
|
79
|
+
conversationSearchBackendPromise ??= detectConversationSearchBackend().catch((error) => {
|
|
80
|
+
log.warn("Conversation search backend detection failed; using native PostgreSQL FTS", {
|
|
81
|
+
error: error instanceof Error ? error.message : String(error),
|
|
82
|
+
});
|
|
83
|
+
return "native";
|
|
84
|
+
});
|
|
85
|
+
return conversationSearchBackendPromise;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isSearchCapabilityError = (error: unknown): boolean =>
|
|
89
|
+
typeof error === "object" && error !== null && "code" in error && SEARCH_CAPABILITY_ERROR_CODES.has(String(error.code));
|
|
90
|
+
|
|
91
|
+
type ConversationRow = {
|
|
92
|
+
id: string;
|
|
93
|
+
short_id: string;
|
|
94
|
+
title: string;
|
|
95
|
+
title_source: string | null;
|
|
96
|
+
description: string | null;
|
|
97
|
+
description_source: string | null;
|
|
98
|
+
keywords: string[] | null;
|
|
99
|
+
pinned_at: Date | string | null;
|
|
100
|
+
archived_at: Date | string | null;
|
|
101
|
+
last_viewed_at: Date | string | null;
|
|
102
|
+
latest_turn_status?: AiTurnStatus | null;
|
|
103
|
+
latest_turn_error?: string | null;
|
|
104
|
+
latest_turn_completed_at?: Date | string | null;
|
|
105
|
+
enrich_fail_count: number | null;
|
|
106
|
+
project_id: string | null;
|
|
107
|
+
draft_content: unknown;
|
|
108
|
+
draft_revision: number | string;
|
|
109
|
+
draft_updated_at: Date | string | null;
|
|
110
|
+
created_by_user_id: string | null;
|
|
111
|
+
launched_by_app_id: string | null;
|
|
112
|
+
allowed_tools: string[] | null;
|
|
113
|
+
created_at: Date | string;
|
|
114
|
+
updated_at: Date | string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type CountRow = {
|
|
118
|
+
total: number | string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type EnrichmentRunRow = {
|
|
122
|
+
id: string;
|
|
123
|
+
conversation_id: string;
|
|
124
|
+
conversation_title?: string;
|
|
125
|
+
status: "ok" | "failed" | "skipped";
|
|
126
|
+
trigger: "scheduled" | "manual";
|
|
127
|
+
model_profile_id: string | null;
|
|
128
|
+
mode: string | null;
|
|
129
|
+
duration_ms: number | string | null;
|
|
130
|
+
title_updated: boolean;
|
|
131
|
+
keywords_count: number | string | null;
|
|
132
|
+
error: string | null;
|
|
133
|
+
created_at: Date | string;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
type MessageRow = {
|
|
137
|
+
id: string;
|
|
138
|
+
short_id: string;
|
|
139
|
+
conversation_id: string;
|
|
140
|
+
seq: number;
|
|
141
|
+
kind: "message" | "summary";
|
|
142
|
+
message: unknown;
|
|
143
|
+
loop_id: string | null;
|
|
144
|
+
model_profile_id: string | null;
|
|
145
|
+
provider_model: string | null;
|
|
146
|
+
usage: unknown;
|
|
147
|
+
stop_reason: string | null;
|
|
148
|
+
loop_aggregate: unknown;
|
|
149
|
+
loop_done_reason: DoneReason | null;
|
|
150
|
+
compacted_at: Date | string | null;
|
|
151
|
+
meta: unknown;
|
|
152
|
+
feedback_rating: number | string | null;
|
|
153
|
+
feedback_reasons: string[] | null;
|
|
154
|
+
feedback_comment: string | null;
|
|
155
|
+
feedback_updated_at: Date | string | null;
|
|
156
|
+
created_at: Date | string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type TurnRow = {
|
|
160
|
+
id: string;
|
|
161
|
+
short_id: string;
|
|
162
|
+
conversation_id: string;
|
|
163
|
+
status: AiTurnStatus;
|
|
164
|
+
attempt: number;
|
|
165
|
+
model_profile_id: string | null;
|
|
166
|
+
created_at: Date | string;
|
|
167
|
+
completed_at: Date | string | null;
|
|
168
|
+
error: string | null;
|
|
169
|
+
run_config?: unknown;
|
|
170
|
+
live_blocks?: unknown;
|
|
171
|
+
live_seq?: number | string;
|
|
172
|
+
lease_owner?: string | null;
|
|
173
|
+
lease_expires_at?: Date | string | null;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
type PendingActionRow = {
|
|
177
|
+
turn_id: string;
|
|
178
|
+
conversation_id: string;
|
|
179
|
+
call_id: string;
|
|
180
|
+
kind: "approval" | "custom_approval" | "client_tool";
|
|
181
|
+
status: "pending" | "resolved" | "aborted";
|
|
182
|
+
tool_name: string;
|
|
183
|
+
args: unknown;
|
|
184
|
+
message: string | null;
|
|
185
|
+
review: unknown | null;
|
|
186
|
+
approval_scope: string;
|
|
187
|
+
allow_always: boolean;
|
|
188
|
+
frontend_mode: AiFrontendToolMode | null;
|
|
189
|
+
resolved_event: unknown | null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
type TurnSteerRow = {
|
|
193
|
+
id: string;
|
|
194
|
+
conversation_id: string;
|
|
195
|
+
turn_id: string;
|
|
196
|
+
seq: number;
|
|
197
|
+
client_request_id: string;
|
|
198
|
+
text: string;
|
|
199
|
+
status: "pending" | "consumed" | "discarded";
|
|
200
|
+
message_id: string | null;
|
|
201
|
+
created_at: Date | string;
|
|
202
|
+
consumed_at: Date | string | null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
type ConversationResourceRefRow = {
|
|
206
|
+
resource_type: string;
|
|
207
|
+
resource_id: string;
|
|
208
|
+
title: string | null;
|
|
209
|
+
preview: string | null;
|
|
210
|
+
icon: string | null;
|
|
211
|
+
href: string | null;
|
|
212
|
+
source_turn_id: string | null;
|
|
213
|
+
source_call_id: string | null;
|
|
214
|
+
first_seen_at: Date | string;
|
|
215
|
+
last_seen_at: Date | string;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
type ConversationResourceOccurrenceRow = ConversationResourceRefRow & {
|
|
219
|
+
conversation_short_id: string;
|
|
220
|
+
conversation_title: string;
|
|
221
|
+
conversation_updated_at: Date | string;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
type ConversationSourceRow = {
|
|
225
|
+
source_kind: AiConversationSource["kind"];
|
|
226
|
+
source_key: string;
|
|
227
|
+
title: string;
|
|
228
|
+
preview: string | null;
|
|
229
|
+
icon: string | null;
|
|
230
|
+
href: string | null;
|
|
231
|
+
path: string | null;
|
|
232
|
+
media_type: string | null;
|
|
233
|
+
size: number | string | null;
|
|
234
|
+
resource_type: string | null;
|
|
235
|
+
resource_id: string | null;
|
|
236
|
+
occurrences: number | string;
|
|
237
|
+
source_turn_id: string | null;
|
|
238
|
+
source_call_id: string | null;
|
|
239
|
+
first_seen_at: Date | string;
|
|
240
|
+
last_seen_at: Date | string;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
type InterChatMessageRow = {
|
|
244
|
+
id: string;
|
|
245
|
+
short_id: string;
|
|
246
|
+
source_conversation_id: string;
|
|
247
|
+
source_chat_id: string;
|
|
248
|
+
source_title: string;
|
|
249
|
+
source_turn_id: string;
|
|
250
|
+
source_turn_short_id: string;
|
|
251
|
+
source_call_id: string;
|
|
252
|
+
target_conversation_id: string;
|
|
253
|
+
target_chat_id: string;
|
|
254
|
+
target_title: string;
|
|
255
|
+
actor_user_id: string;
|
|
256
|
+
text: string;
|
|
257
|
+
status: "pending" | "delivered" | "failed";
|
|
258
|
+
target_turn_id: string | null;
|
|
259
|
+
target_turn_short_id: string | null;
|
|
260
|
+
target_message_id: string | null;
|
|
261
|
+
error: string | null;
|
|
262
|
+
created_at: Date | string;
|
|
263
|
+
delivered_at: Date | string | null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
type TimelineRow = {
|
|
267
|
+
id: string;
|
|
268
|
+
seq: number;
|
|
269
|
+
loop_id: string | null;
|
|
270
|
+
user_preview: string | null;
|
|
271
|
+
assistant_preview: string | null;
|
|
272
|
+
is_steer: boolean;
|
|
273
|
+
input_file_count: number | string | null;
|
|
274
|
+
output_file_count: number | string | null;
|
|
275
|
+
tool_count: number | string | null;
|
|
276
|
+
created_at: Date | string;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const iso = (value: Date | string): string => (value instanceof Date ? value.toISOString() : new Date(value).toISOString());
|
|
280
|
+
|
|
281
|
+
const rowToConversationResourceRef = (row: ConversationResourceRefRow): AiConversationResourceRef => ({
|
|
282
|
+
ref: { type: row.resource_type, id: row.resource_id },
|
|
283
|
+
title: row.title,
|
|
284
|
+
preview: row.preview,
|
|
285
|
+
icon: row.icon,
|
|
286
|
+
href: row.href,
|
|
287
|
+
sourceTurnId: row.source_turn_id,
|
|
288
|
+
sourceCallId: row.source_call_id,
|
|
289
|
+
firstSeenAt: iso(row.first_seen_at),
|
|
290
|
+
lastSeenAt: iso(row.last_seen_at),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
type ResourceCursor = { at: string; type: string; id: string };
|
|
294
|
+
const encodeResourceCursor = (row: ConversationResourceRefRow): string =>
|
|
295
|
+
encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
|
|
296
|
+
const encodeSourceCursor = (row: ConversationSourceRow): string =>
|
|
297
|
+
encodeURIComponent(JSON.stringify({ at: iso(row.last_seen_at), type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
|
|
298
|
+
const decodeResourceCursor = (value: string | undefined): ResourceCursor | null => {
|
|
299
|
+
if (!value) return null;
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(decodeURIComponent(value)) as Partial<ResourceCursor>;
|
|
302
|
+
return typeof parsed.at === "string" &&
|
|
303
|
+
!Number.isNaN(Date.parse(parsed.at)) &&
|
|
304
|
+
typeof parsed.type === "string" &&
|
|
305
|
+
typeof parsed.id === "string"
|
|
306
|
+
? { at: parsed.at, type: parsed.type, id: parsed.id }
|
|
307
|
+
: null;
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const rowToConversationResourceOccurrence = (row: ConversationResourceOccurrenceRow): AiConversationResourceOccurrence => ({
|
|
314
|
+
...rowToConversationResourceRef(row),
|
|
315
|
+
chat: {
|
|
316
|
+
shortId: row.conversation_short_id,
|
|
317
|
+
title: row.conversation_title,
|
|
318
|
+
updatedAt: iso(row.conversation_updated_at),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const rowToConversationSource = (row: ConversationSourceRow): AiConversationSource => ({
|
|
323
|
+
kind: row.source_kind,
|
|
324
|
+
key: row.source_key,
|
|
325
|
+
title: row.title,
|
|
326
|
+
preview: row.preview,
|
|
327
|
+
icon: row.icon || "ti ti-link",
|
|
328
|
+
href: row.href,
|
|
329
|
+
path: row.path,
|
|
330
|
+
mediaType: row.media_type,
|
|
331
|
+
size: row.size === null ? null : Number(row.size),
|
|
332
|
+
ref: row.resource_type && row.resource_id ? { type: row.resource_type, id: row.resource_id } : null,
|
|
333
|
+
occurrences: Number(row.occurrences),
|
|
334
|
+
firstSeenAt: iso(row.first_seen_at),
|
|
335
|
+
lastSeenAt: iso(row.last_seen_at),
|
|
336
|
+
sourceTurnId: row.source_turn_id,
|
|
337
|
+
sourceCallId: row.source_call_id,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage => ({
|
|
341
|
+
id: row.id,
|
|
342
|
+
shortId: row.short_id,
|
|
343
|
+
sourceConversationId: row.source_conversation_id,
|
|
344
|
+
sourceChatId: row.source_chat_id,
|
|
345
|
+
sourceTitle: row.source_title,
|
|
346
|
+
sourceTurnId: row.source_turn_id,
|
|
347
|
+
sourceTurnShortId: row.source_turn_short_id,
|
|
348
|
+
sourceCallId: row.source_call_id,
|
|
349
|
+
targetConversationId: row.target_conversation_id,
|
|
350
|
+
targetChatId: row.target_chat_id,
|
|
351
|
+
targetTitle: row.target_title,
|
|
352
|
+
actorUserId: row.actor_user_id,
|
|
353
|
+
text: row.text,
|
|
354
|
+
status: row.status,
|
|
355
|
+
targetTurnId: row.target_turn_id,
|
|
356
|
+
targetTurnShortId: row.target_turn_short_id,
|
|
357
|
+
targetMessageId: row.target_message_id,
|
|
358
|
+
error: row.error,
|
|
359
|
+
createdAt: iso(row.created_at),
|
|
360
|
+
deliveredAt: row.delivered_at ? iso(row.delivered_at) : null,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const interChatMessageSelect = sql`
|
|
364
|
+
SELECT message.id, message.short_id, message.source_conversation_id,
|
|
365
|
+
source.short_id AS source_chat_id, source.title AS source_title,
|
|
366
|
+
message.source_turn_id, source_turn.short_id AS source_turn_short_id, message.source_call_id,
|
|
367
|
+
message.target_conversation_id, target.short_id AS target_chat_id, target.title AS target_title,
|
|
368
|
+
message.actor_user_id, message.text, message.status, message.target_turn_id,
|
|
369
|
+
target_turn.short_id AS target_turn_short_id, message.target_message_id, message.error,
|
|
370
|
+
message.created_at, message.delivered_at
|
|
371
|
+
FROM ai.inter_chat_messages message
|
|
372
|
+
JOIN ai.conversations source ON source.id = message.source_conversation_id
|
|
373
|
+
JOIN ai.turns source_turn ON source_turn.id = message.source_turn_id
|
|
374
|
+
JOIN ai.conversations target ON target.id = message.target_conversation_id
|
|
375
|
+
LEFT JOIN ai.turns target_turn ON target_turn.id = message.target_turn_id
|
|
376
|
+
`;
|
|
377
|
+
|
|
378
|
+
type ResourceOccurrenceCursor = ResourceCursor & { chat: string };
|
|
379
|
+
const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow): string =>
|
|
380
|
+
encodeURIComponent(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
at: iso(row.last_seen_at),
|
|
383
|
+
type: row.resource_type,
|
|
384
|
+
id: row.resource_id,
|
|
385
|
+
chat: row.conversation_short_id,
|
|
386
|
+
} satisfies ResourceOccurrenceCursor),
|
|
387
|
+
);
|
|
388
|
+
const decodeResourceOccurrenceCursor = (value: string | undefined): ResourceOccurrenceCursor | null => {
|
|
389
|
+
if (!value) return null;
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(decodeURIComponent(value)) as Partial<ResourceOccurrenceCursor>;
|
|
392
|
+
return typeof parsed.at === "string" &&
|
|
393
|
+
!Number.isNaN(Date.parse(parsed.at)) &&
|
|
394
|
+
typeof parsed.type === "string" &&
|
|
395
|
+
typeof parsed.id === "string" &&
|
|
396
|
+
typeof parsed.chat === "string"
|
|
397
|
+
? { at: parsed.at, type: parsed.type, id: parsed.id, chat: parsed.chat }
|
|
398
|
+
: null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const sanitizePagination = (input: { page: number; perPage: number }) => {
|
|
405
|
+
const page = Number.isInteger(input.page) && input.page > 0 ? input.page : 1;
|
|
406
|
+
const perPage = Number.isInteger(input.perPage) && input.perPage > 0 ? Math.min(input.perPage, 100) : 20;
|
|
407
|
+
return { page, perPage, offset: (page - 1) * perPage };
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const searchPattern = (value: string | undefined): string | null => {
|
|
411
|
+
const trimmed = value?.trim().toLowerCase();
|
|
412
|
+
return trimmed ? `%${trimmed}%` : null;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const conversationSearchRank = (backend: ConversationSearchBackend, query: string) => {
|
|
416
|
+
const exactBoost = sql`
|
|
417
|
+
CASE
|
|
418
|
+
WHEN LOWER(conversation.title) = LOWER(${query}) THEN 40
|
|
419
|
+
WHEN LOWER(conversation.title) LIKE ${`%${query.toLowerCase()}%`} THEN 20
|
|
420
|
+
WHEN LOWER(conversation.search_summary) LIKE ${`%${query.toLowerCase()}%`} THEN 8
|
|
421
|
+
WHEN LOWER(conversation.description) LIKE ${`%${query.toLowerCase()}%`} THEN 4
|
|
422
|
+
ELSE 0
|
|
423
|
+
END
|
|
424
|
+
`;
|
|
425
|
+
if (backend === "bm25") {
|
|
426
|
+
return sql`${exactBoost}
|
|
427
|
+
- (conversation.search_text <@> to_bm25query(${query}, ${CONVERSATION_BM25_INDEX}))
|
|
428
|
+
+ COALESCE((
|
|
429
|
+
SELECT MAX(-(message.search_text <@> to_bm25query(${query}, ${MESSAGE_BM25_INDEX})))
|
|
430
|
+
FROM ai.messages message
|
|
431
|
+
WHERE message.conversation_id = conversation.id AND message.search_text <> ''
|
|
432
|
+
), 0)`;
|
|
433
|
+
}
|
|
434
|
+
return sql`${exactBoost}
|
|
435
|
+
+ ts_rank_cd(conversation.search_document, websearch_to_tsquery('simple', ${query})) * 10
|
|
436
|
+
+ COALESCE((
|
|
437
|
+
SELECT MAX(ts_rank_cd(message.search_document, websearch_to_tsquery('simple', ${query})))
|
|
438
|
+
FROM ai.messages message
|
|
439
|
+
WHERE message.conversation_id = conversation.id
|
|
440
|
+
), 0)`;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const withConversationSearchBackend = async <T>(
|
|
444
|
+
query: string | null,
|
|
445
|
+
run: (backend: ConversationSearchBackend) => Promise<T>,
|
|
446
|
+
): Promise<T> => {
|
|
447
|
+
const backend = query ? await getConversationSearchBackend() : "native";
|
|
448
|
+
try {
|
|
449
|
+
return await run(backend);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (backend !== "bm25" || !isSearchCapabilityError(error)) throw error;
|
|
452
|
+
log.warn("Conversation BM25 query failed; falling back to native PostgreSQL FTS", {
|
|
453
|
+
error: error instanceof Error ? error.message : String(error),
|
|
454
|
+
});
|
|
455
|
+
conversationSearchBackendPromise = Promise.resolve("native");
|
|
456
|
+
return run("native");
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const parseJsonValue = <T>(value: unknown): T => {
|
|
461
|
+
if (typeof value === "string") return JSON.parse(value) as T;
|
|
462
|
+
return value as T;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | undefined => {
|
|
466
|
+
if (value === null || value === undefined) return undefined;
|
|
467
|
+
const parsed = CapabilityActionReviewSchema.safeParse(parseJsonValue(value));
|
|
468
|
+
return parsed.success ? parsed.data : undefined;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const fieldSource = (value: string | null): AiConversation["titleSource"] => (value === "auto" || value === "user" ? value : "default");
|
|
472
|
+
|
|
473
|
+
const conversationRunStatus = (status: AiTurnStatus | null | undefined): AiConversation["runStatus"] => {
|
|
474
|
+
if (status === "queued") return "queued";
|
|
475
|
+
if (status === "running") return "running";
|
|
476
|
+
if (status === "waiting_for_action") return "needs_attention";
|
|
477
|
+
if (status === "failed") return "failed";
|
|
478
|
+
return "idle";
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
482
|
+
id: row.id,
|
|
483
|
+
shortId: row.short_id,
|
|
484
|
+
title: row.title,
|
|
485
|
+
titleSource: fieldSource(row.title_source),
|
|
486
|
+
description: row.description ?? "",
|
|
487
|
+
descriptionSource: fieldSource(row.description_source),
|
|
488
|
+
keywords: row.keywords ?? [],
|
|
489
|
+
pinnedAt: row.pinned_at ? iso(row.pinned_at) : null,
|
|
490
|
+
archivedAt: row.archived_at ? iso(row.archived_at) : null,
|
|
491
|
+
runStatus: conversationRunStatus(row.latest_turn_status),
|
|
492
|
+
runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
|
|
493
|
+
unreadCompletion:
|
|
494
|
+
row.latest_turn_status === "completed" &&
|
|
495
|
+
Boolean(row.latest_turn_completed_at) &&
|
|
496
|
+
(!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime()),
|
|
497
|
+
projectId: row.project_id,
|
|
498
|
+
draft: {
|
|
499
|
+
content: parseJsonValue<AiConversationDraft["content"]>(row.draft_content ?? []),
|
|
500
|
+
revision: Number(row.draft_revision ?? 0),
|
|
501
|
+
updatedAt: row.draft_updated_at ? iso(row.draft_updated_at) : null,
|
|
502
|
+
},
|
|
503
|
+
createdByUserId: row.created_by_user_id,
|
|
504
|
+
launchedByAppId: row.launched_by_app_id,
|
|
505
|
+
allowedTools: row.allowed_tools ?? null,
|
|
506
|
+
createdAt: iso(row.created_at),
|
|
507
|
+
updatedAt: iso(row.updated_at),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const rowToEnrichmentRun = (row: EnrichmentRunRow): AiEnrichmentRun => ({
|
|
511
|
+
id: row.id,
|
|
512
|
+
conversationId: row.conversation_id,
|
|
513
|
+
status: row.status,
|
|
514
|
+
trigger: row.trigger,
|
|
515
|
+
modelProfileId: row.model_profile_id,
|
|
516
|
+
mode: row.mode,
|
|
517
|
+
durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
|
|
518
|
+
titleUpdated: row.title_updated,
|
|
519
|
+
keywordsCount: Number(row.keywords_count ?? 0),
|
|
520
|
+
error: row.error,
|
|
521
|
+
createdAt: iso(row.created_at),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const rowToEnrichmentOverviewRun = (row: EnrichmentRunRow): AiEnrichmentOverviewRun => ({
|
|
525
|
+
...rowToEnrichmentRun(row),
|
|
526
|
+
conversationTitle: row.conversation_title ?? "",
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const numberOrNull = (value: number | string | null | undefined): number | null => {
|
|
530
|
+
if (value === null || value === undefined) return null;
|
|
531
|
+
const parsed = Number(value);
|
|
532
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const intValue = (value: number | string | null | undefined): number => Math.max(0, Math.trunc(numberOrNull(value) ?? 0));
|
|
536
|
+
|
|
537
|
+
const rowToMessage = (row: MessageRow): AiStoredMessage => {
|
|
538
|
+
const message = parseJsonValue<Message>(row.message);
|
|
539
|
+
return {
|
|
540
|
+
id: row.id,
|
|
541
|
+
shortId: row.short_id,
|
|
542
|
+
conversationId: row.conversation_id,
|
|
543
|
+
seq: row.seq,
|
|
544
|
+
kind: row.kind,
|
|
545
|
+
message,
|
|
546
|
+
loopId: row.loop_id,
|
|
547
|
+
modelProfileId: row.model_profile_id,
|
|
548
|
+
providerModel: row.provider_model,
|
|
549
|
+
usage: row.usage ? parseJsonValue<Usage>(row.usage) : null,
|
|
550
|
+
stopReason: row.stop_reason,
|
|
551
|
+
loopAggregate: row.loop_aggregate ? parseJsonValue<LoopAggregate>(row.loop_aggregate) : null,
|
|
552
|
+
loopDoneReason: row.loop_done_reason,
|
|
553
|
+
compactedAt: row.compacted_at ? iso(row.compacted_at) : null,
|
|
554
|
+
meta: row.meta ? parseJsonValue<AiStoredMessage["meta"]>(row.meta) : null,
|
|
555
|
+
feedback:
|
|
556
|
+
Number(row.feedback_rating) === 1 || Number(row.feedback_rating) === -1
|
|
557
|
+
? {
|
|
558
|
+
rating: Number(row.feedback_rating) === 1 ? "up" : "down",
|
|
559
|
+
reasons: (row.feedback_reasons ?? []) as NonNullable<AiStoredMessage["feedback"]>["reasons"],
|
|
560
|
+
comment: row.feedback_comment,
|
|
561
|
+
updatedAt: iso(row.feedback_updated_at!),
|
|
562
|
+
}
|
|
563
|
+
: null,
|
|
564
|
+
createdAt: iso(row.created_at),
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const rowToTurn = (row: TurnRow): AiTurn => ({
|
|
569
|
+
id: row.id,
|
|
570
|
+
shortId: row.short_id,
|
|
571
|
+
conversationId: row.conversation_id,
|
|
572
|
+
status: row.status,
|
|
573
|
+
attempt: Number(row.attempt ?? 0),
|
|
574
|
+
modelProfileId: row.model_profile_id,
|
|
575
|
+
createdAt: iso(row.created_at),
|
|
576
|
+
completedAt: row.completed_at ? iso(row.completed_at) : null,
|
|
577
|
+
error: row.error,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const rowToLiveBlocks = (row: TurnRow): AiTurnBlock[] => {
|
|
581
|
+
if (!row.live_blocks) return [];
|
|
582
|
+
const parsed = parseJsonValue<AiTurnBlock[]>(row.live_blocks);
|
|
583
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction =>
|
|
587
|
+
row.kind === "client_tool"
|
|
588
|
+
? {
|
|
589
|
+
type: "frontend_tool",
|
|
590
|
+
turnId: row.turn_id,
|
|
591
|
+
conversationId: row.conversation_id,
|
|
592
|
+
callId: row.call_id,
|
|
593
|
+
name: row.tool_name,
|
|
594
|
+
args: parseJsonValue(row.args),
|
|
595
|
+
mode: row.frontend_mode ?? "client",
|
|
596
|
+
}
|
|
597
|
+
: {
|
|
598
|
+
type: "approval_request",
|
|
599
|
+
turnId: row.turn_id,
|
|
600
|
+
conversationId: row.conversation_id,
|
|
601
|
+
callId: row.call_id,
|
|
602
|
+
name: row.tool_name,
|
|
603
|
+
args: parseJsonValue(row.args),
|
|
604
|
+
message: row.message ?? undefined,
|
|
605
|
+
review: parseCapabilityActionReview(row.review),
|
|
606
|
+
allowAlways: row.allow_always,
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRecord => ({
|
|
610
|
+
turnId: row.turn_id,
|
|
611
|
+
conversationId: row.conversation_id,
|
|
612
|
+
callId: row.call_id,
|
|
613
|
+
kind: row.kind,
|
|
614
|
+
status: row.status,
|
|
615
|
+
name: row.tool_name,
|
|
616
|
+
args: parseJsonValue(row.args),
|
|
617
|
+
message: row.message ?? undefined,
|
|
618
|
+
review: parseCapabilityActionReview(row.review),
|
|
619
|
+
approvalScope: row.approval_scope,
|
|
620
|
+
allowAlways: row.allow_always,
|
|
621
|
+
frontendMode: row.frontend_mode ?? undefined,
|
|
622
|
+
resolvedEvent: row.resolved_event ? parseJsonValue<InboundEvent>(row.resolved_event) : null,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const rowToTurnSteer = (row: TurnSteerRow): AiTurnSteer => ({
|
|
626
|
+
id: row.id,
|
|
627
|
+
conversationId: row.conversation_id,
|
|
628
|
+
turnId: row.turn_id,
|
|
629
|
+
seq: Number(row.seq),
|
|
630
|
+
clientRequestId: row.client_request_id,
|
|
631
|
+
text: row.text,
|
|
632
|
+
status: row.status,
|
|
633
|
+
messageId: row.message_id,
|
|
634
|
+
createdAt: iso(row.created_at),
|
|
635
|
+
consumedAt: row.consumed_at ? iso(row.consumed_at) : null,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const boundedMs = (value: number, fallback: number, min: number, max: number): number => {
|
|
639
|
+
if (!Number.isFinite(value)) return fallback;
|
|
640
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const loadConversationSummary = async (input: {
|
|
644
|
+
conversationId?: string;
|
|
645
|
+
shortId?: string;
|
|
646
|
+
ownerUserId?: string;
|
|
647
|
+
archived?: boolean;
|
|
648
|
+
}): Promise<AiConversation | null> => {
|
|
649
|
+
const rows = await sql<ConversationRow[]>`
|
|
650
|
+
SELECT
|
|
651
|
+
conversation.*,
|
|
652
|
+
latest.status AS latest_turn_status,
|
|
653
|
+
latest.error AS latest_turn_error,
|
|
654
|
+
latest.completed_at AS latest_turn_completed_at
|
|
655
|
+
FROM ai.conversations conversation
|
|
656
|
+
LEFT JOIN LATERAL (
|
|
657
|
+
SELECT status, error, completed_at
|
|
658
|
+
FROM ai.turns
|
|
659
|
+
WHERE conversation_id = conversation.id
|
|
660
|
+
ORDER BY created_at DESC, id DESC
|
|
661
|
+
LIMIT 1
|
|
662
|
+
) latest ON TRUE
|
|
663
|
+
WHERE (${input.conversationId ?? null}::uuid IS NULL OR conversation.id = ${input.conversationId ?? null}::uuid)
|
|
664
|
+
AND (${input.shortId ?? null}::text IS NULL OR conversation.short_id = ${input.shortId ?? null})
|
|
665
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR conversation.created_by_user_id = ${input.ownerUserId ?? null})
|
|
666
|
+
AND (${Boolean(input.archived)}::boolean = (conversation.archived_at IS NOT NULL))
|
|
667
|
+
LIMIT 1
|
|
668
|
+
`;
|
|
669
|
+
return rows[0] ? rowToConversation(rows[0]) : null;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const firstText = (message: Message): string => {
|
|
673
|
+
if (message.role !== "user") return "";
|
|
674
|
+
const part = message.content.find(
|
|
675
|
+
(entry): entry is string | { type: "text"; text: string } => typeof entry === "string" || entry.type === "text",
|
|
676
|
+
);
|
|
677
|
+
if (!part) return "";
|
|
678
|
+
const text = typeof part === "string" ? part : part.text;
|
|
679
|
+
return text.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const messageColumns = (message: Message) => ({
|
|
683
|
+
usage: message.role === "assistant" ? (message.usage ?? null) : null,
|
|
684
|
+
providerModel: message.role === "assistant" ? (message.model ?? null) : null,
|
|
685
|
+
stopReason: message.role === "assistant" ? (message.stopReason ?? null) : null,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const messageSearchText = (message: Message): string => {
|
|
689
|
+
if (message.role === "tool_result") return "";
|
|
690
|
+
const text = message.content
|
|
691
|
+
.map((part) => {
|
|
692
|
+
if (typeof part === "string") return part;
|
|
693
|
+
if (part.type === "text") return part.text;
|
|
694
|
+
return "";
|
|
695
|
+
})
|
|
696
|
+
.filter(Boolean)
|
|
697
|
+
.join("\n")
|
|
698
|
+
.trim();
|
|
699
|
+
return text.slice(0, SEARCH_TEXT_MAX_CHARS);
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
703
|
+
const insertMessageLocked = async (
|
|
704
|
+
input: {
|
|
705
|
+
conversationId: string;
|
|
706
|
+
message: Message;
|
|
707
|
+
kind?: "message" | "summary";
|
|
708
|
+
seq?: number;
|
|
709
|
+
loopId?: string | null;
|
|
710
|
+
modelProfileId?: string | null;
|
|
711
|
+
meta?: AiStoredMessage["meta"];
|
|
712
|
+
},
|
|
713
|
+
db: typeof sql = sql,
|
|
714
|
+
): Promise<MessageRow> => {
|
|
715
|
+
const { usage, providerModel, stopReason } = messageColumns(input.message);
|
|
716
|
+
const seqRows = input.seq
|
|
717
|
+
? [{ seq: input.seq }]
|
|
718
|
+
: await db<
|
|
719
|
+
{ seq: number }[]
|
|
720
|
+
>`SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0`;
|
|
721
|
+
const seq = seqRows[0]?.seq ?? 1;
|
|
722
|
+
|
|
723
|
+
const rows = await withAiShortIdForDb(
|
|
724
|
+
db,
|
|
725
|
+
"idx_ai_messages_conversation_short_id",
|
|
726
|
+
(attempt, shortId) => attempt<MessageRow[]>`
|
|
727
|
+
INSERT INTO ai.messages (
|
|
728
|
+
short_id,
|
|
729
|
+
conversation_id,
|
|
730
|
+
seq,
|
|
731
|
+
kind,
|
|
732
|
+
role,
|
|
733
|
+
message,
|
|
734
|
+
search_text,
|
|
735
|
+
loop_id,
|
|
736
|
+
model_profile_id,
|
|
737
|
+
provider_model,
|
|
738
|
+
usage,
|
|
739
|
+
stop_reason,
|
|
740
|
+
meta
|
|
741
|
+
)
|
|
742
|
+
VALUES (
|
|
743
|
+
${shortId},
|
|
744
|
+
${input.conversationId},
|
|
745
|
+
${seq},
|
|
746
|
+
${input.kind ?? "message"},
|
|
747
|
+
${input.message.role},
|
|
748
|
+
${JSON.stringify(input.message)}::jsonb,
|
|
749
|
+
${messageSearchText(input.message)},
|
|
750
|
+
${input.loopId ?? null},
|
|
751
|
+
${input.modelProfileId ?? null},
|
|
752
|
+
${providerModel},
|
|
753
|
+
${usage ? JSON.stringify(usage) : null}::jsonb,
|
|
754
|
+
${stopReason},
|
|
755
|
+
${input.meta ? JSON.stringify(input.meta) : null}::jsonb
|
|
756
|
+
)
|
|
757
|
+
RETURNING *
|
|
758
|
+
`,
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const title = firstText(input.message);
|
|
762
|
+
if (seq === 1 && title) {
|
|
763
|
+
// First-message snapshot title stays title_source 'default': it is a
|
|
764
|
+
// placeholder ("Hi", …) that the enrichment job may replace freely.
|
|
765
|
+
// 'auto' is reserved for enrichment-set titles.
|
|
766
|
+
await db`
|
|
767
|
+
UPDATE ai.conversations
|
|
768
|
+
SET title = ${title}, updated_at = now()
|
|
769
|
+
WHERE id = ${input.conversationId}
|
|
770
|
+
`;
|
|
771
|
+
} else {
|
|
772
|
+
await db`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
|
|
773
|
+
}
|
|
774
|
+
return rows[0]!;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
/** Append a turn-owned (assistant/tool_result/summary) message, guarded by lease ownership in one statement. */
|
|
778
|
+
const appendTurnOwnedMessage = async (input: {
|
|
779
|
+
conversationId: string;
|
|
780
|
+
turnId: string;
|
|
781
|
+
leaseOwner: string;
|
|
782
|
+
message: Message;
|
|
783
|
+
kind?: "message" | "summary";
|
|
784
|
+
seq?: number;
|
|
785
|
+
loopId: string | null;
|
|
786
|
+
modelProfileId?: string | null;
|
|
787
|
+
meta?: AiStoredMessage["meta"];
|
|
788
|
+
}): Promise<boolean> => {
|
|
789
|
+
const { usage, providerModel, stopReason } = messageColumns(input.message);
|
|
790
|
+
const rows = await withAiShortId(
|
|
791
|
+
"idx_ai_messages_conversation_short_id",
|
|
792
|
+
(shortId) => sql<{ id: string }[]>`
|
|
793
|
+
INSERT INTO ai.messages (
|
|
794
|
+
short_id,
|
|
795
|
+
conversation_id,
|
|
796
|
+
seq,
|
|
797
|
+
kind,
|
|
798
|
+
role,
|
|
799
|
+
message,
|
|
800
|
+
loop_id,
|
|
801
|
+
model_profile_id,
|
|
802
|
+
provider_model,
|
|
803
|
+
usage,
|
|
804
|
+
stop_reason,
|
|
805
|
+
meta
|
|
806
|
+
)
|
|
807
|
+
SELECT
|
|
808
|
+
${shortId},
|
|
809
|
+
${input.conversationId},
|
|
810
|
+
CASE
|
|
811
|
+
WHEN ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
|
|
812
|
+
ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0)
|
|
813
|
+
END,
|
|
814
|
+
${input.kind ?? "message"},
|
|
815
|
+
${input.message.role},
|
|
816
|
+
${JSON.stringify(input.message)}::jsonb,
|
|
817
|
+
${input.loopId},
|
|
818
|
+
${input.modelProfileId ?? null},
|
|
819
|
+
${providerModel},
|
|
820
|
+
${usage ? JSON.stringify(usage) : null}::jsonb,
|
|
821
|
+
${stopReason},
|
|
822
|
+
${input.meta ? JSON.stringify(input.meta) : null}::jsonb
|
|
823
|
+
WHERE EXISTS (
|
|
824
|
+
SELECT 1
|
|
825
|
+
FROM ai.turns
|
|
826
|
+
WHERE id = ${input.turnId}
|
|
827
|
+
AND conversation_id = ${input.conversationId}
|
|
828
|
+
AND status IN ('running', 'waiting_for_action')
|
|
829
|
+
AND lease_owner = ${input.leaseOwner}
|
|
830
|
+
)
|
|
831
|
+
RETURNING id
|
|
832
|
+
`,
|
|
833
|
+
);
|
|
834
|
+
if (rows[0]) {
|
|
835
|
+
await sql`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const toolMessageMeta = (
|
|
842
|
+
message: Message,
|
|
843
|
+
presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
|
|
844
|
+
rejectedToolCallIds: ReadonlySet<string> | undefined,
|
|
845
|
+
): AiStoredMessage["meta"] => {
|
|
846
|
+
if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
|
|
847
|
+
return { toolOutcomes: { [message.callId]: "rejected" } };
|
|
848
|
+
}
|
|
849
|
+
if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
|
|
850
|
+
const toolPresentations = Object.fromEntries(
|
|
851
|
+
message.content.flatMap((block) => {
|
|
852
|
+
if (block.type !== "tool_call") return [];
|
|
853
|
+
const presentation = presentations.get(block.name);
|
|
854
|
+
return presentation ? ([[block.id, presentation]] as const) : [];
|
|
855
|
+
}),
|
|
856
|
+
);
|
|
857
|
+
return Object.keys(toolPresentations).length > 0 ? { toolPresentations } : null;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
export const aiConversations: AiConversationService = {
|
|
861
|
+
createConversation: async (input) => {
|
|
862
|
+
const rows = await withAiShortId(
|
|
863
|
+
"idx_ai_conversations_short_id",
|
|
864
|
+
(shortId) => sql<ConversationRow[]>`
|
|
865
|
+
INSERT INTO ai.conversations (
|
|
866
|
+
short_id,
|
|
867
|
+
title,
|
|
868
|
+
description,
|
|
869
|
+
project_id,
|
|
870
|
+
draft_content,
|
|
871
|
+
draft_revision,
|
|
872
|
+
loaded_tools,
|
|
873
|
+
draft_updated_at,
|
|
874
|
+
created_by_user_id,
|
|
875
|
+
launched_by_app_id,
|
|
876
|
+
allowed_tools
|
|
877
|
+
)
|
|
878
|
+
SELECT
|
|
879
|
+
${shortId},
|
|
880
|
+
${input.title?.trim() || "New chat"},
|
|
881
|
+
${input.description?.trim() ?? ""},
|
|
882
|
+
${input.projectId ?? null}::uuid,
|
|
883
|
+
${JSON.stringify(input.draft ?? [])}::jsonb,
|
|
884
|
+
${input.draft?.length ? 1 : 0},
|
|
885
|
+
${toPgTextArray(input.preloadTools ?? [])}::text[],
|
|
886
|
+
${input.draft?.length ? new Date() : null},
|
|
887
|
+
${input.ownerUserId},
|
|
888
|
+
${input.launchedByAppId?.trim() || null},
|
|
889
|
+
${input.allowedTools === undefined ? null : toPgTextArray(input.allowedTools)}::text[]
|
|
890
|
+
WHERE ${input.projectId ?? null}::uuid IS NULL
|
|
891
|
+
OR EXISTS (SELECT 1 FROM ai.projects project WHERE project.id = ${input.projectId ?? null}::uuid)
|
|
892
|
+
RETURNING *
|
|
893
|
+
`,
|
|
894
|
+
);
|
|
895
|
+
if (!rows[0]) throw new Error("Project does not exist.");
|
|
896
|
+
return rowToConversation(rows[0]);
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
forkConversation: async (input) => {
|
|
900
|
+
return sql.begin(async (tx) => {
|
|
901
|
+
const source = await tx<ConversationRow[]>`
|
|
902
|
+
SELECT * FROM ai.conversations WHERE id = ${input.sourceConversationId}::uuid FOR SHARE
|
|
903
|
+
`;
|
|
904
|
+
if (!source[0]) throw new Error("Source conversation no longer exists.");
|
|
905
|
+
const rows = await withAiShortIdForDb(
|
|
906
|
+
tx,
|
|
907
|
+
"idx_ai_conversations_short_id",
|
|
908
|
+
(attempt, shortId) => attempt<ConversationRow[]>`
|
|
909
|
+
INSERT INTO ai.conversations (
|
|
910
|
+
short_id, title, description, project_id, created_by_user_id, allowed_tools
|
|
911
|
+
)
|
|
912
|
+
SELECT
|
|
913
|
+
${shortId}, ${input.title?.trim() || source[0]!.title}, description, project_id, ${input.ownerUserId}::uuid, allowed_tools
|
|
914
|
+
FROM ai.conversations WHERE id = ${input.sourceConversationId}::uuid
|
|
915
|
+
RETURNING *
|
|
916
|
+
`,
|
|
917
|
+
);
|
|
918
|
+
const target = rows[0];
|
|
919
|
+
if (!target) throw new Error("Failed to fork conversation.");
|
|
920
|
+
await tx`
|
|
921
|
+
INSERT INTO ai.messages (
|
|
922
|
+
short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
|
|
923
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
|
|
924
|
+
)
|
|
925
|
+
SELECT
|
|
926
|
+
short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
|
|
927
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
|
|
928
|
+
FROM ai.messages
|
|
929
|
+
WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
930
|
+
AND compacted_at IS NULL
|
|
931
|
+
AND seq <= ${Math.floor(input.throughSeq)}
|
|
932
|
+
ORDER BY seq ASC
|
|
933
|
+
`;
|
|
934
|
+
await tx`
|
|
935
|
+
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
936
|
+
SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, updated_at
|
|
937
|
+
FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
938
|
+
`;
|
|
939
|
+
return rowToConversation(target);
|
|
940
|
+
});
|
|
941
|
+
},
|
|
942
|
+
|
|
943
|
+
listConversations: async (input) => {
|
|
944
|
+
const pattern = searchPattern(input.search);
|
|
945
|
+
const query = input.search?.trim() || null;
|
|
946
|
+
const archived = Boolean(input.archived);
|
|
947
|
+
const status = input.status ?? null;
|
|
948
|
+
const limit = input.limit && input.limit > 0 ? Math.min(input.limit, 500) : 100;
|
|
949
|
+
const refs = input.refs ?? [];
|
|
950
|
+
const rows = await withConversationSearchBackend(query, async (backend) => {
|
|
951
|
+
const order = query
|
|
952
|
+
? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
|
|
953
|
+
: sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
|
|
954
|
+
return sql<ConversationRow[]>`
|
|
955
|
+
SELECT
|
|
956
|
+
conversation.*,
|
|
957
|
+
latest.status AS latest_turn_status,
|
|
958
|
+
latest.error AS latest_turn_error,
|
|
959
|
+
latest.completed_at AS latest_turn_completed_at
|
|
960
|
+
FROM ai.conversations conversation
|
|
961
|
+
LEFT JOIN LATERAL (
|
|
962
|
+
SELECT status, error, completed_at
|
|
963
|
+
FROM ai.turns
|
|
964
|
+
WHERE conversation_id = conversation.id
|
|
965
|
+
ORDER BY created_at DESC, id DESC
|
|
966
|
+
LIMIT 1
|
|
967
|
+
) latest ON TRUE
|
|
968
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
969
|
+
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
970
|
+
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
971
|
+
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
972
|
+
AND (${refs.length === 0}::boolean OR NOT EXISTS (
|
|
973
|
+
SELECT 1
|
|
974
|
+
FROM jsonb_to_recordset((${JSON.stringify(refs)}::text)::jsonb) requested(type text, id text)
|
|
975
|
+
WHERE NOT EXISTS (
|
|
976
|
+
SELECT 1
|
|
977
|
+
FROM ai.conversation_resource_refs indexed
|
|
978
|
+
WHERE indexed.conversation_id = conversation.id
|
|
979
|
+
AND indexed.resource_type = requested.type
|
|
980
|
+
AND indexed.resource_id = requested.id
|
|
981
|
+
)
|
|
982
|
+
))
|
|
983
|
+
AND (${pattern}::text IS NULL
|
|
984
|
+
OR LOWER(conversation.title) LIKE ${pattern}
|
|
985
|
+
OR LOWER(conversation.description) LIKE ${pattern}
|
|
986
|
+
OR LOWER(conversation.search_summary) LIKE ${pattern}
|
|
987
|
+
OR LOWER(array_to_string(conversation.keywords, ' ')) LIKE ${pattern}
|
|
988
|
+
OR conversation.search_document @@ websearch_to_tsquery('simple', ${query ?? ""})
|
|
989
|
+
OR EXISTS (
|
|
990
|
+
SELECT 1 FROM ai.messages message
|
|
991
|
+
WHERE message.conversation_id = conversation.id
|
|
992
|
+
AND (LOWER(message.search_text) LIKE ${pattern}
|
|
993
|
+
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
994
|
+
))
|
|
995
|
+
AND (${status}::text IS NULL
|
|
996
|
+
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
997
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
998
|
+
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
999
|
+
OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
|
|
1000
|
+
ORDER BY ${order}
|
|
1001
|
+
LIMIT ${limit}
|
|
1002
|
+
`;
|
|
1003
|
+
});
|
|
1004
|
+
return rows.map(rowToConversation);
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
listSidebarConversations: async (input) => {
|
|
1008
|
+
const unassignedLimit = Math.min(Math.max(input.unassignedLimit ?? 15, 1), 50);
|
|
1009
|
+
const perProjectLimit = Math.min(Math.max(input.perProjectLimit ?? 10, 1), 50);
|
|
1010
|
+
const rows = await sql<ConversationRow[]>`
|
|
1011
|
+
WITH ranked AS (
|
|
1012
|
+
SELECT
|
|
1013
|
+
conversation.*,
|
|
1014
|
+
latest.status AS latest_turn_status,
|
|
1015
|
+
latest.error AS latest_turn_error,
|
|
1016
|
+
latest.completed_at AS latest_turn_completed_at,
|
|
1017
|
+
row_number() OVER (
|
|
1018
|
+
PARTITION BY conversation.project_id
|
|
1019
|
+
ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id
|
|
1020
|
+
) AS sidebar_rank
|
|
1021
|
+
FROM ai.conversations conversation
|
|
1022
|
+
LEFT JOIN LATERAL (
|
|
1023
|
+
SELECT status, error, completed_at
|
|
1024
|
+
FROM ai.turns
|
|
1025
|
+
WHERE conversation_id = conversation.id
|
|
1026
|
+
ORDER BY created_at DESC, id DESC
|
|
1027
|
+
LIMIT 1
|
|
1028
|
+
) latest ON TRUE
|
|
1029
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
|
|
1030
|
+
AND conversation.archived_at IS NULL
|
|
1031
|
+
)
|
|
1032
|
+
SELECT *
|
|
1033
|
+
FROM ranked
|
|
1034
|
+
WHERE (project_id IS NULL AND sidebar_rank <= ${unassignedLimit})
|
|
1035
|
+
OR (project_id IS NOT NULL AND sidebar_rank <= ${perProjectLimit})
|
|
1036
|
+
ORDER BY pinned_at DESC NULLS LAST, updated_at DESC, created_at DESC, id
|
|
1037
|
+
`;
|
|
1038
|
+
return rows.map(rowToConversation);
|
|
1039
|
+
},
|
|
1040
|
+
|
|
1041
|
+
listConversationsPage: async (input): Promise<AiConversationPage> => {
|
|
1042
|
+
const pattern = searchPattern(input.search);
|
|
1043
|
+
const query = input.search?.trim() || null;
|
|
1044
|
+
const archived = Boolean(input.archived);
|
|
1045
|
+
const status = input.status ?? null;
|
|
1046
|
+
const { page, perPage, offset } = sanitizePagination(input);
|
|
1047
|
+
const rows = await withConversationSearchBackend(query, async (backend) => {
|
|
1048
|
+
const order = query
|
|
1049
|
+
? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
|
|
1050
|
+
: sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
|
|
1051
|
+
return sql<ConversationRow[]>`
|
|
1052
|
+
SELECT
|
|
1053
|
+
conversation.*,
|
|
1054
|
+
latest.status AS latest_turn_status,
|
|
1055
|
+
latest.error AS latest_turn_error,
|
|
1056
|
+
latest.completed_at AS latest_turn_completed_at
|
|
1057
|
+
FROM ai.conversations conversation
|
|
1058
|
+
LEFT JOIN LATERAL (
|
|
1059
|
+
SELECT status, error, completed_at
|
|
1060
|
+
FROM ai.turns
|
|
1061
|
+
WHERE conversation_id = conversation.id
|
|
1062
|
+
ORDER BY created_at DESC, id DESC
|
|
1063
|
+
LIMIT 1
|
|
1064
|
+
) latest ON TRUE
|
|
1065
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
1066
|
+
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1067
|
+
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
1068
|
+
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
1069
|
+
AND (${pattern}::text IS NULL
|
|
1070
|
+
OR LOWER(conversation.title) LIKE ${pattern}
|
|
1071
|
+
OR LOWER(conversation.description) LIKE ${pattern}
|
|
1072
|
+
OR LOWER(conversation.search_summary) LIKE ${pattern}
|
|
1073
|
+
OR LOWER(array_to_string(conversation.keywords, ' ')) LIKE ${pattern}
|
|
1074
|
+
OR conversation.search_document @@ websearch_to_tsquery('simple', ${query ?? ""})
|
|
1075
|
+
OR EXISTS (
|
|
1076
|
+
SELECT 1 FROM ai.messages message
|
|
1077
|
+
WHERE message.conversation_id = conversation.id
|
|
1078
|
+
AND (LOWER(message.search_text) LIKE ${pattern}
|
|
1079
|
+
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
1080
|
+
))
|
|
1081
|
+
AND (${status}::text IS NULL
|
|
1082
|
+
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
1083
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1084
|
+
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
1085
|
+
OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
|
|
1086
|
+
ORDER BY ${order}
|
|
1087
|
+
LIMIT ${perPage}
|
|
1088
|
+
OFFSET ${offset}
|
|
1089
|
+
`;
|
|
1090
|
+
});
|
|
1091
|
+
const countRows = await sql<CountRow[]>`
|
|
1092
|
+
SELECT COUNT(*) AS total
|
|
1093
|
+
FROM ai.conversations conversation
|
|
1094
|
+
LEFT JOIN LATERAL (
|
|
1095
|
+
SELECT status, completed_at
|
|
1096
|
+
FROM ai.turns
|
|
1097
|
+
WHERE conversation_id = conversation.id
|
|
1098
|
+
ORDER BY created_at DESC, id DESC
|
|
1099
|
+
LIMIT 1
|
|
1100
|
+
) latest ON TRUE
|
|
1101
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
1102
|
+
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1103
|
+
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
1104
|
+
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
1105
|
+
AND (${pattern}::text IS NULL
|
|
1106
|
+
OR LOWER(conversation.title) LIKE ${pattern}
|
|
1107
|
+
OR LOWER(conversation.description) LIKE ${pattern}
|
|
1108
|
+
OR LOWER(conversation.search_summary) LIKE ${pattern}
|
|
1109
|
+
OR LOWER(array_to_string(conversation.keywords, ' ')) LIKE ${pattern}
|
|
1110
|
+
OR conversation.search_document @@ websearch_to_tsquery('simple', ${query ?? ""})
|
|
1111
|
+
OR EXISTS (
|
|
1112
|
+
SELECT 1 FROM ai.messages message
|
|
1113
|
+
WHERE message.conversation_id = conversation.id
|
|
1114
|
+
AND (LOWER(message.search_text) LIKE ${pattern}
|
|
1115
|
+
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
1116
|
+
))
|
|
1117
|
+
AND (${status}::text IS NULL
|
|
1118
|
+
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
1119
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1120
|
+
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
1121
|
+
OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
|
|
1122
|
+
`;
|
|
1123
|
+
const total = Number(countRows[0]?.total ?? 0);
|
|
1124
|
+
return {
|
|
1125
|
+
items: rows.map(rowToConversation),
|
|
1126
|
+
total,
|
|
1127
|
+
page,
|
|
1128
|
+
perPage,
|
|
1129
|
+
hasNext: page * perPage < total,
|
|
1130
|
+
};
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
getConversation: async (input) => {
|
|
1134
|
+
return loadConversationSummary(input);
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1137
|
+
getConversationByShortId: async (input) => {
|
|
1138
|
+
return loadConversationSummary(input);
|
|
1139
|
+
},
|
|
1140
|
+
|
|
1141
|
+
saveDraft: async (input) =>
|
|
1142
|
+
sql.begin(async (tx) => {
|
|
1143
|
+
const [conversation] = await tx<
|
|
1144
|
+
{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]
|
|
1145
|
+
>`
|
|
1146
|
+
SELECT draft_content, draft_revision, draft_updated_at
|
|
1147
|
+
FROM ai.conversations
|
|
1148
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1149
|
+
AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
1150
|
+
AND archived_at IS NULL
|
|
1151
|
+
FOR UPDATE
|
|
1152
|
+
`;
|
|
1153
|
+
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
1154
|
+
if (Number(conversation.draft_revision) !== input.expectedRevision) {
|
|
1155
|
+
return { ok: false as const, reason: "conflict" as const };
|
|
1156
|
+
}
|
|
1157
|
+
const [sameDraft] = await tx<{ same: boolean }[]>`
|
|
1158
|
+
SELECT ${JSON.stringify(input.content)}::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::jsonb AS same
|
|
1159
|
+
`;
|
|
1160
|
+
if (sameDraft?.same) {
|
|
1161
|
+
return {
|
|
1162
|
+
ok: true as const,
|
|
1163
|
+
draft: {
|
|
1164
|
+
content: input.content,
|
|
1165
|
+
revision: Number(conversation.draft_revision),
|
|
1166
|
+
updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
|
|
1167
|
+
},
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
for (const part of input.content) {
|
|
1172
|
+
if (part.type !== "file") continue;
|
|
1173
|
+
const [file] = await tx<{ exists: boolean }[]>`
|
|
1174
|
+
SELECT EXISTS (
|
|
1175
|
+
SELECT 1 FROM ai.files
|
|
1176
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1177
|
+
AND path = ${part.path}
|
|
1178
|
+
AND media_type = ${part.mediaType}
|
|
1179
|
+
AND size = ${part.size}
|
|
1180
|
+
AND version = ${part.version}
|
|
1181
|
+
) AS exists
|
|
1182
|
+
`;
|
|
1183
|
+
if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
|
|
1187
|
+
UPDATE ai.conversations
|
|
1188
|
+
SET draft_content = ${JSON.stringify(input.content)}::jsonb,
|
|
1189
|
+
draft_revision = draft_revision + 1,
|
|
1190
|
+
draft_updated_at = now()
|
|
1191
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1192
|
+
RETURNING draft_content, draft_revision, draft_updated_at
|
|
1193
|
+
`;
|
|
1194
|
+
return {
|
|
1195
|
+
ok: true as const,
|
|
1196
|
+
draft: {
|
|
1197
|
+
content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
|
|
1198
|
+
revision: Number(saved!.draft_revision),
|
|
1199
|
+
updatedAt: iso(saved!.draft_updated_at),
|
|
1200
|
+
},
|
|
1201
|
+
};
|
|
1202
|
+
}),
|
|
1203
|
+
|
|
1204
|
+
getLoadedTools: async (input) => {
|
|
1205
|
+
const rows = await sql<{ loaded_tools: string[] | null }[]>`
|
|
1206
|
+
SELECT loaded_tools
|
|
1207
|
+
FROM ai.conversations
|
|
1208
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1209
|
+
AND archived_at IS NULL
|
|
1210
|
+
`;
|
|
1211
|
+
if (!rows[0]) throw new Error(`AI conversation ${input.conversationId} not found`);
|
|
1212
|
+
return rows[0].loaded_tools ?? [];
|
|
1213
|
+
},
|
|
1214
|
+
|
|
1215
|
+
loadTools: async (input) =>
|
|
1216
|
+
sql.begin(async (tx) => {
|
|
1217
|
+
const rows = await tx<{ loaded_tools: string[] | null }[]>`
|
|
1218
|
+
SELECT loaded_tools
|
|
1219
|
+
FROM ai.conversations
|
|
1220
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1221
|
+
AND archived_at IS NULL
|
|
1222
|
+
FOR UPDATE
|
|
1223
|
+
`;
|
|
1224
|
+
if (!rows[0]) throw new Error(`AI conversation ${input.conversationId} not found`);
|
|
1225
|
+
|
|
1226
|
+
const current = rows[0].loaded_tools ?? [];
|
|
1227
|
+
const requested = [...new Set(input.names.map((name) => name.trim()).filter(Boolean))];
|
|
1228
|
+
const currentSet = new Set(current);
|
|
1229
|
+
const alreadyLoaded = requested.filter((name) => currentSet.has(name));
|
|
1230
|
+
const added = requested.filter((name) => !currentSet.has(name));
|
|
1231
|
+
const combined = [...current, ...added];
|
|
1232
|
+
const configuredLimit = Math.floor(input.maxLoadedTools ?? 0);
|
|
1233
|
+
const limit = Number.isFinite(configuredLimit) && configuredLimit > 0 ? configuredLimit : 0;
|
|
1234
|
+
const evicted = limit > 0 ? combined.slice(0, Math.max(0, combined.length - limit)) : [];
|
|
1235
|
+
const retained = evicted.length > 0 ? combined.slice(evicted.length) : combined;
|
|
1236
|
+
const retainedSet = new Set(retained);
|
|
1237
|
+
const loaded = added.filter((name) => retainedSet.has(name));
|
|
1238
|
+
|
|
1239
|
+
await tx`
|
|
1240
|
+
UPDATE ai.conversations
|
|
1241
|
+
SET loaded_tools = ${toPgTextArray(retained)}::text[]
|
|
1242
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1243
|
+
`;
|
|
1244
|
+
return { loaded, alreadyLoaded, evicted };
|
|
1245
|
+
}),
|
|
1246
|
+
|
|
1247
|
+
indexConversationResources: async (input) => {
|
|
1248
|
+
const resources = [...new Map(input.resources.map((resource) => [`${resource.ref.type}\0${resource.ref.id}`, resource])).values()];
|
|
1249
|
+
if (!resources.length) return;
|
|
1250
|
+
await sql.begin(async (tx) => {
|
|
1251
|
+
for (const resource of resources) {
|
|
1252
|
+
await tx`
|
|
1253
|
+
INSERT INTO ai.conversation_resource_refs (
|
|
1254
|
+
conversation_id, resource_type, resource_id, title, preview, icon, href, source_turn_id, source_call_id
|
|
1255
|
+
) VALUES (
|
|
1256
|
+
${input.conversationId}, ${resource.ref.type}, ${resource.ref.id}, ${resource.title ?? null}, ${resource.preview ?? null},
|
|
1257
|
+
${resource.icon ?? null}, ${resource.href ?? null}, ${input.turnId ?? null}, ${input.callId ?? null}
|
|
1258
|
+
)
|
|
1259
|
+
ON CONFLICT (conversation_id, resource_type, resource_id)
|
|
1260
|
+
DO UPDATE SET
|
|
1261
|
+
title = COALESCE(EXCLUDED.title, ai.conversation_resource_refs.title),
|
|
1262
|
+
preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
|
|
1263
|
+
icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
|
|
1264
|
+
href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
|
|
1265
|
+
source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id),
|
|
1266
|
+
source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_resource_refs.source_call_id),
|
|
1267
|
+
last_seen_at = now()
|
|
1268
|
+
`;
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
},
|
|
1272
|
+
|
|
1273
|
+
listConversationResources: async (input) => {
|
|
1274
|
+
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1275
|
+
const pattern = searchPattern(input.search);
|
|
1276
|
+
const cursor = decodeResourceCursor(input.before);
|
|
1277
|
+
const rows = await sql<ConversationResourceRefRow[]>`
|
|
1278
|
+
SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
|
|
1279
|
+
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
|
|
1280
|
+
FROM ai.conversation_resource_refs indexed
|
|
1281
|
+
LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
|
|
1282
|
+
WHERE indexed.conversation_id = ${input.conversationId}::uuid
|
|
1283
|
+
AND (${pattern}::text IS NULL OR LOWER(COALESCE(title, '') || ' ' || resource_type || ' ' || resource_id) LIKE ${pattern})
|
|
1284
|
+
AND (${cursor?.at ?? null}::timestamptz IS NULL OR (last_seen_at, resource_type, resource_id) <
|
|
1285
|
+
(${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text))
|
|
1286
|
+
ORDER BY last_seen_at DESC, resource_type DESC, resource_id DESC
|
|
1287
|
+
LIMIT ${limit + 1}
|
|
1288
|
+
`;
|
|
1289
|
+
const page = rows.slice(0, limit);
|
|
1290
|
+
return {
|
|
1291
|
+
resources: page.map(rowToConversationResourceRef),
|
|
1292
|
+
...(rows.length > limit && page.at(-1) ? { nextCursor: encodeResourceCursor(page.at(-1)!) } : {}),
|
|
1293
|
+
};
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
listUserConversationResources: async (input) => {
|
|
1297
|
+
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1298
|
+
const pattern = searchPattern(input.search);
|
|
1299
|
+
const cursor = decodeResourceOccurrenceCursor(input.before);
|
|
1300
|
+
const rows = await sql<ConversationResourceOccurrenceRow[]>`
|
|
1301
|
+
SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
|
|
1302
|
+
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
|
|
1303
|
+
conversation.short_id AS conversation_short_id, conversation.title AS conversation_title,
|
|
1304
|
+
conversation.updated_at AS conversation_updated_at
|
|
1305
|
+
FROM ai.conversation_resource_refs indexed
|
|
1306
|
+
JOIN ai.conversations conversation ON conversation.id = indexed.conversation_id
|
|
1307
|
+
LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
|
|
1308
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
|
|
1309
|
+
AND conversation.archived_at IS NULL
|
|
1310
|
+
AND (${pattern}::text IS NULL OR LOWER(COALESCE(indexed.title, '') || ' ' || indexed.resource_type || ' ' || indexed.resource_id || ' ' || conversation.title) LIKE ${pattern})
|
|
1311
|
+
AND (${cursor?.at ?? null}::timestamptz IS NULL OR (indexed.last_seen_at, indexed.resource_type, indexed.resource_id, conversation.short_id) <
|
|
1312
|
+
(${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text, ${cursor?.chat ?? null}::text))
|
|
1313
|
+
ORDER BY indexed.last_seen_at DESC, indexed.resource_type DESC, indexed.resource_id DESC, conversation.short_id DESC
|
|
1314
|
+
LIMIT ${limit + 1}
|
|
1315
|
+
`;
|
|
1316
|
+
const page = rows.slice(0, limit);
|
|
1317
|
+
return {
|
|
1318
|
+
resources: page.map(rowToConversationResourceOccurrence),
|
|
1319
|
+
...(rows.length > limit && page.at(-1) ? { nextCursor: encodeResourceOccurrenceCursor(page.at(-1)!) } : {}),
|
|
1320
|
+
};
|
|
1321
|
+
},
|
|
1322
|
+
|
|
1323
|
+
indexConversationSource: async (input) => {
|
|
1324
|
+
const source = input.source;
|
|
1325
|
+
await sql`
|
|
1326
|
+
INSERT INTO ai.conversation_sources (
|
|
1327
|
+
conversation_id, kind, source_key, title, preview, icon, href, source_turn_id, source_call_id
|
|
1328
|
+
) VALUES (
|
|
1329
|
+
${input.conversationId}::uuid, ${source.kind}, ${source.key}, ${source.title}, ${source.preview ?? null},
|
|
1330
|
+
${source.icon ?? null}, ${source.href ?? null}, ${input.turnId ?? null}::uuid, ${input.callId ?? null}
|
|
1331
|
+
)
|
|
1332
|
+
ON CONFLICT (conversation_id, kind, source_key)
|
|
1333
|
+
DO UPDATE SET
|
|
1334
|
+
title = EXCLUDED.title,
|
|
1335
|
+
preview = COALESCE(EXCLUDED.preview, ai.conversation_sources.preview),
|
|
1336
|
+
icon = COALESCE(EXCLUDED.icon, ai.conversation_sources.icon),
|
|
1337
|
+
href = COALESCE(EXCLUDED.href, ai.conversation_sources.href),
|
|
1338
|
+
occurrences = ai.conversation_sources.occurrences +
|
|
1339
|
+
CASE WHEN ai.conversation_sources.source_call_id IS DISTINCT FROM EXCLUDED.source_call_id THEN 1 ELSE 0 END,
|
|
1340
|
+
source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_sources.source_turn_id),
|
|
1341
|
+
source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_sources.source_call_id),
|
|
1342
|
+
last_seen_at = now()
|
|
1343
|
+
`;
|
|
1344
|
+
},
|
|
1345
|
+
|
|
1346
|
+
listConversationSources: async (input) => {
|
|
1347
|
+
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1348
|
+
const pattern = searchPattern(input.search);
|
|
1349
|
+
const cursor = decodeResourceCursor(input.before);
|
|
1350
|
+
const rows = await sql<ConversationSourceRow[]>`
|
|
1351
|
+
SELECT *
|
|
1352
|
+
FROM (
|
|
1353
|
+
SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
|
|
1354
|
+
source.icon, source.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
|
|
1355
|
+
NULL::text AS resource_type, NULL::text AS resource_id, source.occurrences,
|
|
1356
|
+
turn.short_id AS source_turn_id, source.source_call_id, source.first_seen_at, source.last_seen_at
|
|
1357
|
+
FROM ai.conversation_sources source
|
|
1358
|
+
LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
|
|
1359
|
+
WHERE source.conversation_id = ${input.conversationId}::uuid
|
|
1360
|
+
|
|
1361
|
+
UNION ALL
|
|
1362
|
+
|
|
1363
|
+
SELECT 'file' AS source_kind, file.path AS source_key,
|
|
1364
|
+
regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
|
|
1365
|
+
CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
|
|
1366
|
+
NULL::text AS href, file.path, file.media_type, file.size,
|
|
1367
|
+
NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
|
|
1368
|
+
NULL::text AS source_turn_id, NULL::text AS source_call_id,
|
|
1369
|
+
file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
|
|
1370
|
+
FROM ai.files file
|
|
1371
|
+
WHERE file.conversation_id = ${input.conversationId}::uuid
|
|
1372
|
+
|
|
1373
|
+
UNION ALL
|
|
1374
|
+
|
|
1375
|
+
SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
|
|
1376
|
+
COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
|
|
1377
|
+
indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
|
|
1378
|
+
indexed.resource_type, indexed.resource_id, 1 AS occurrences,
|
|
1379
|
+
turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
|
|
1380
|
+
FROM ai.conversation_resource_refs indexed
|
|
1381
|
+
LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
|
|
1382
|
+
WHERE indexed.conversation_id = ${input.conversationId}::uuid
|
|
1383
|
+
) source
|
|
1384
|
+
WHERE (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
|
|
1385
|
+
AND (${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
|
|
1386
|
+
(${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text))
|
|
1387
|
+
ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
|
|
1388
|
+
LIMIT ${limit + 1}
|
|
1389
|
+
`;
|
|
1390
|
+
const page = rows.slice(0, limit);
|
|
1391
|
+
return {
|
|
1392
|
+
sources: page.map(rowToConversationSource),
|
|
1393
|
+
...(rows.length > limit && page.at(-1) ? { nextCursor: encodeSourceCursor(page.at(-1)!) } : {}),
|
|
1394
|
+
};
|
|
1395
|
+
},
|
|
1396
|
+
|
|
1397
|
+
getCapabilityInvocationOrigin: async (input) => {
|
|
1398
|
+
const rows = await sql<
|
|
1399
|
+
{ conversation_id: string; conversation_short_id: string; turn_id: string; turn_short_id: string; call_id: string }[]
|
|
1400
|
+
>`
|
|
1401
|
+
SELECT call.conversation_id, conversation.short_id AS conversation_short_id,
|
|
1402
|
+
call.turn_id, turn.short_id AS turn_short_id, call.call_id
|
|
1403
|
+
FROM ai.tool_calls call
|
|
1404
|
+
JOIN ai.conversations conversation ON conversation.id = call.conversation_id
|
|
1405
|
+
JOIN ai.turns turn ON turn.id = call.turn_id
|
|
1406
|
+
WHERE call.idempotency_key = ${input.idempotencyKey}
|
|
1407
|
+
AND call.tool_name = ${input.toolName}
|
|
1408
|
+
LIMIT 1
|
|
1409
|
+
`;
|
|
1410
|
+
const row = rows[0];
|
|
1411
|
+
return row
|
|
1412
|
+
? {
|
|
1413
|
+
conversationId: row.conversation_id,
|
|
1414
|
+
conversationShortId: row.conversation_short_id,
|
|
1415
|
+
turnId: row.turn_id,
|
|
1416
|
+
turnShortId: row.turn_short_id,
|
|
1417
|
+
callId: row.call_id,
|
|
1418
|
+
}
|
|
1419
|
+
: null;
|
|
1420
|
+
},
|
|
1421
|
+
|
|
1422
|
+
createInterChatMessage: async (input) => {
|
|
1423
|
+
const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect}
|
|
1424
|
+
WHERE message.idempotency_key = ${input.idempotencyKey}
|
|
1425
|
+
AND message.source_conversation_id = ${input.sourceConversationId}::uuid
|
|
1426
|
+
AND message.actor_user_id = ${input.actorUserId}::uuid
|
|
1427
|
+
LIMIT 1`;
|
|
1428
|
+
if (existing[0]) return { ok: true, message: rowToInterChatMessage(existing[0]) };
|
|
1429
|
+
|
|
1430
|
+
const [pair] = await sql<{ source_id: string; target_id: string }[]>`
|
|
1431
|
+
SELECT source.id AS source_id, target.id AS target_id
|
|
1432
|
+
FROM ai.conversations source
|
|
1433
|
+
JOIN ai.turns source_turn ON source_turn.conversation_id = source.id AND source_turn.id = ${input.sourceTurnId}::uuid
|
|
1434
|
+
JOIN ai.conversations target ON target.short_id = ${input.targetChatId}
|
|
1435
|
+
WHERE source.id = ${input.sourceConversationId}::uuid
|
|
1436
|
+
AND source.created_by_user_id = ${input.actorUserId}::uuid
|
|
1437
|
+
AND target.created_by_user_id = ${input.actorUserId}::uuid
|
|
1438
|
+
AND source.archived_at IS NULL
|
|
1439
|
+
AND target.archived_at IS NULL
|
|
1440
|
+
LIMIT 1
|
|
1441
|
+
`;
|
|
1442
|
+
if (!pair) return { ok: false, reason: "not_found" };
|
|
1443
|
+
if (pair.source_id === pair.target_id) return { ok: false, reason: "same_chat" };
|
|
1444
|
+
const [recursive] = await sql<{ recursive: boolean }[]>`
|
|
1445
|
+
SELECT EXISTS (
|
|
1446
|
+
SELECT 1 FROM ai.messages
|
|
1447
|
+
WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
1448
|
+
AND loop_id = ${input.sourceTurnId}
|
|
1449
|
+
AND meta ? 'agentMessage'
|
|
1450
|
+
) AS recursive
|
|
1451
|
+
`;
|
|
1452
|
+
if (recursive?.recursive) return { ok: false, reason: "recursive" };
|
|
1453
|
+
|
|
1454
|
+
const inserted = await withAiShortId(
|
|
1455
|
+
"ai_inter_chat_messages_short_id_unique",
|
|
1456
|
+
(shortId) => sql<{ id: string }[]>`
|
|
1457
|
+
INSERT INTO ai.inter_chat_messages (
|
|
1458
|
+
short_id, source_conversation_id, source_turn_id, source_call_id,
|
|
1459
|
+
target_conversation_id, actor_user_id, text, idempotency_key
|
|
1460
|
+
) VALUES (
|
|
1461
|
+
${shortId}, ${input.sourceConversationId}, ${input.sourceTurnId}, ${input.sourceCallId},
|
|
1462
|
+
${pair.target_id}, ${input.actorUserId}, ${input.text.trim()}, ${input.idempotencyKey}
|
|
1463
|
+
)
|
|
1464
|
+
ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
|
|
1465
|
+
RETURNING id
|
|
1466
|
+
`,
|
|
1467
|
+
);
|
|
1468
|
+
const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
|
|
1469
|
+
return { ok: true, message: rowToInterChatMessage(rows[0]!) };
|
|
1470
|
+
},
|
|
1471
|
+
|
|
1472
|
+
listPendingInterChatMessages: async (input = {}) => {
|
|
1473
|
+
const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 100);
|
|
1474
|
+
const rows = await sql<InterChatMessageRow[]>`
|
|
1475
|
+
${interChatMessageSelect}
|
|
1476
|
+
WHERE message.status = 'pending'
|
|
1477
|
+
AND (${input.targetConversationId ?? null}::uuid IS NULL OR message.target_conversation_id = ${input.targetConversationId ?? null}::uuid)
|
|
1478
|
+
AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR NOT EXISTS (
|
|
1479
|
+
SELECT 1
|
|
1480
|
+
FROM ai.turns active_turn
|
|
1481
|
+
WHERE active_turn.conversation_id = message.target_conversation_id
|
|
1482
|
+
AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
|
|
1483
|
+
))
|
|
1484
|
+
AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR message.id = (
|
|
1485
|
+
SELECT oldest.id
|
|
1486
|
+
FROM ai.inter_chat_messages oldest
|
|
1487
|
+
WHERE oldest.target_conversation_id = message.target_conversation_id
|
|
1488
|
+
AND oldest.status = 'pending'
|
|
1489
|
+
ORDER BY oldest.created_at ASC, oldest.id ASC
|
|
1490
|
+
LIMIT 1
|
|
1491
|
+
))
|
|
1492
|
+
ORDER BY message.created_at ASC, message.id ASC
|
|
1493
|
+
LIMIT ${limit}
|
|
1494
|
+
`;
|
|
1495
|
+
return rows.map(rowToInterChatMessage);
|
|
1496
|
+
},
|
|
1497
|
+
|
|
1498
|
+
failInterChatMessage: async (input) => {
|
|
1499
|
+
const rows = await sql<{ id: string }[]>`
|
|
1500
|
+
UPDATE ai.inter_chat_messages
|
|
1501
|
+
SET status = 'failed', error = ${input.error.slice(0, 2_000)}
|
|
1502
|
+
WHERE id = ${input.messageId}::uuid AND status = 'pending'
|
|
1503
|
+
RETURNING id
|
|
1504
|
+
`;
|
|
1505
|
+
return Boolean(rows[0]);
|
|
1506
|
+
},
|
|
1507
|
+
|
|
1508
|
+
deliverInterChatMessage: async (input) => {
|
|
1509
|
+
const delivered = await sql.begin(async (tx) => {
|
|
1510
|
+
const rows = await tx<InterChatMessageRow[]>`
|
|
1511
|
+
${interChatMessageSelect}
|
|
1512
|
+
WHERE message.id = ${input.messageId}::uuid
|
|
1513
|
+
FOR UPDATE OF message
|
|
1514
|
+
`;
|
|
1515
|
+
const message = rows[0];
|
|
1516
|
+
if (!message) return { delivered: false as const, reason: "not_found" as const };
|
|
1517
|
+
if (message.status === "delivered") {
|
|
1518
|
+
const turn = message.target_turn_id
|
|
1519
|
+
? await tx<TurnRow[]>`SELECT * FROM ai.turns WHERE id = ${message.target_turn_id}::uuid LIMIT 1`
|
|
1520
|
+
: [];
|
|
1521
|
+
return turn[0]
|
|
1522
|
+
? { delivered: true as const, message: rowToInterChatMessage(message), turn: rowToTurn(turn[0]) }
|
|
1523
|
+
: { delivered: false as const, reason: "failed" as const };
|
|
1524
|
+
}
|
|
1525
|
+
if (message.status === "failed") return { delivered: false as const, reason: "failed" as const };
|
|
1526
|
+
|
|
1527
|
+
const [target] = await tx<{ archived_at: Date | string | null }[]>`
|
|
1528
|
+
SELECT archived_at FROM ai.conversations WHERE id = ${message.target_conversation_id}::uuid FOR UPDATE
|
|
1529
|
+
`;
|
|
1530
|
+
if (!target || target.archived_at) {
|
|
1531
|
+
await tx`UPDATE ai.inter_chat_messages SET status = 'failed', error = 'Target chat is unavailable' WHERE id = ${message.id}::uuid`;
|
|
1532
|
+
return { delivered: false as const, reason: "failed" as const };
|
|
1533
|
+
}
|
|
1534
|
+
const [active] = await tx<{ id: string }[]>`
|
|
1535
|
+
SELECT id FROM ai.turns
|
|
1536
|
+
WHERE conversation_id = ${message.target_conversation_id}::uuid
|
|
1537
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1538
|
+
LIMIT 1
|
|
1539
|
+
`;
|
|
1540
|
+
if (active) return { delivered: false as const, reason: "busy" as const };
|
|
1541
|
+
|
|
1542
|
+
const turnRows = await withAiShortIdForDb(
|
|
1543
|
+
tx,
|
|
1544
|
+
"idx_ai_turns_conversation_short_id",
|
|
1545
|
+
(attempt, shortId) => attempt<TurnRow[]>`
|
|
1546
|
+
INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
|
|
1547
|
+
VALUES (${shortId}, ${message.target_conversation_id}, ${input.modelProfileId}, 'queued', (${JSON.stringify(input.runConfig)}::text)::jsonb)
|
|
1548
|
+
RETURNING *
|
|
1549
|
+
`,
|
|
1550
|
+
);
|
|
1551
|
+
const turn = rowToTurn(turnRows[0]!);
|
|
1552
|
+
const targetMeta: NonNullable<AiStoredMessage["meta"]> = {
|
|
1553
|
+
agentMessage: {
|
|
1554
|
+
id: message.short_id,
|
|
1555
|
+
sourceChatId: message.source_chat_id,
|
|
1556
|
+
sourceTurnId: message.source_turn_short_id,
|
|
1557
|
+
sourceTitle: message.source_title,
|
|
1558
|
+
sourceHref: input.sourceHref,
|
|
1559
|
+
},
|
|
1560
|
+
};
|
|
1561
|
+
const messageRows = await withAiShortIdForDb(
|
|
1562
|
+
tx,
|
|
1563
|
+
"idx_ai_messages_conversation_short_id",
|
|
1564
|
+
(attempt, shortId) => attempt<MessageRow[]>`
|
|
1565
|
+
INSERT INTO ai.messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
|
|
1566
|
+
VALUES (
|
|
1567
|
+
${shortId}, ${message.target_conversation_id},
|
|
1568
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${message.target_conversation_id} AND seq > 0),
|
|
1569
|
+
'message', ${input.userMessage.role}, (${JSON.stringify(input.userMessage)}::text)::jsonb,
|
|
1570
|
+
${messageSearchText(input.userMessage)}, ${turn.id}, (${JSON.stringify(targetMeta)}::text)::jsonb
|
|
1571
|
+
)
|
|
1572
|
+
RETURNING *
|
|
1573
|
+
`,
|
|
1574
|
+
);
|
|
1575
|
+
const messageRow = messageRows[0]!;
|
|
1576
|
+
await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${message.target_conversation_id}::uuid`;
|
|
1577
|
+
await tx`
|
|
1578
|
+
UPDATE ai.inter_chat_messages
|
|
1579
|
+
SET status = 'delivered', target_turn_id = ${turn.id}, target_message_id = ${messageRow.id}, delivered_at = now()
|
|
1580
|
+
WHERE id = ${message.id}::uuid
|
|
1581
|
+
`;
|
|
1582
|
+
const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${message.id}::uuid LIMIT 1`;
|
|
1583
|
+
return { delivered: true as const, message: rowToInterChatMessage(updated[0]!), turn };
|
|
1584
|
+
});
|
|
1585
|
+
return delivered;
|
|
1586
|
+
},
|
|
1587
|
+
|
|
1588
|
+
updateConversationMetadata: async (input) => {
|
|
1589
|
+
const title = input.title.trim() || "New chat";
|
|
1590
|
+
const description = input.description?.trim() ?? "";
|
|
1591
|
+
const pinned = input.pinned ?? null;
|
|
1592
|
+
const rows = await sql<{ id: string }[]>`
|
|
1593
|
+
UPDATE ai.conversations
|
|
1594
|
+
SET title = ${title},
|
|
1595
|
+
title_source = CASE WHEN title IS DISTINCT FROM ${title} THEN 'user' ELSE title_source END,
|
|
1596
|
+
description = ${description},
|
|
1597
|
+
description_source = CASE WHEN description IS DISTINCT FROM ${description} THEN 'user' ELSE description_source END,
|
|
1598
|
+
pinned_at = CASE
|
|
1599
|
+
WHEN ${pinned}::boolean IS NULL THEN pinned_at
|
|
1600
|
+
WHEN ${pinned} THEN COALESCE(pinned_at, now())
|
|
1601
|
+
ELSE NULL
|
|
1602
|
+
END,
|
|
1603
|
+
updated_at = CASE
|
|
1604
|
+
WHEN title IS DISTINCT FROM ${title} OR description IS DISTINCT FROM ${description} THEN now()
|
|
1605
|
+
ELSE updated_at
|
|
1606
|
+
END
|
|
1607
|
+
WHERE id = ${input.conversationId}
|
|
1608
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1609
|
+
AND archived_at IS NULL
|
|
1610
|
+
RETURNING id
|
|
1611
|
+
`;
|
|
1612
|
+
return rows[0] ? loadConversationSummary(input) : null;
|
|
1613
|
+
},
|
|
1614
|
+
|
|
1615
|
+
setConversationProject: async (input) => {
|
|
1616
|
+
return sql.begin(async (tx) => {
|
|
1617
|
+
const conversations = await tx<ConversationRow[]>`
|
|
1618
|
+
SELECT *
|
|
1619
|
+
FROM ai.conversations
|
|
1620
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1621
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1622
|
+
AND archived_at IS NULL
|
|
1623
|
+
FOR UPDATE
|
|
1624
|
+
`;
|
|
1625
|
+
if (!conversations[0]) return { ok: false as const, reason: "not_found" as const };
|
|
1626
|
+
|
|
1627
|
+
const [activity] = await tx<{ exists: boolean }[]>`
|
|
1628
|
+
SELECT EXISTS (
|
|
1629
|
+
SELECT 1 FROM ai.turns
|
|
1630
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1631
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1632
|
+
) AS exists
|
|
1633
|
+
`;
|
|
1634
|
+
if (activity?.exists) return { ok: false as const, reason: "active_turn" as const };
|
|
1635
|
+
|
|
1636
|
+
const updated = await tx<ConversationRow[]>`
|
|
1637
|
+
UPDATE ai.conversations
|
|
1638
|
+
SET project_id = ${input.projectId}::uuid,
|
|
1639
|
+
updated_at = CASE WHEN project_id IS DISTINCT FROM ${input.projectId}::uuid THEN now() ELSE updated_at END
|
|
1640
|
+
WHERE id = ${input.conversationId}::uuid
|
|
1641
|
+
AND (${input.projectId}::uuid IS NULL OR EXISTS (SELECT 1 FROM ai.projects WHERE id = ${input.projectId}::uuid))
|
|
1642
|
+
RETURNING *
|
|
1643
|
+
`;
|
|
1644
|
+
return updated[0]
|
|
1645
|
+
? { ok: true as const, conversation: rowToConversation(updated[0]) }
|
|
1646
|
+
: { ok: false as const, reason: "not_found" as const };
|
|
1647
|
+
});
|
|
1648
|
+
},
|
|
1649
|
+
|
|
1650
|
+
setConversationPinned: async (input) => {
|
|
1651
|
+
const rows = await sql<{ id: string }[]>`
|
|
1652
|
+
UPDATE ai.conversations
|
|
1653
|
+
SET pinned_at = CASE WHEN ${input.pinned} THEN COALESCE(pinned_at, now()) ELSE NULL END
|
|
1654
|
+
WHERE id = ${input.conversationId}
|
|
1655
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1656
|
+
AND archived_at IS NULL
|
|
1657
|
+
RETURNING id
|
|
1658
|
+
`;
|
|
1659
|
+
return rows[0] ? loadConversationSummary(input) : null;
|
|
1660
|
+
},
|
|
1661
|
+
|
|
1662
|
+
archiveConversation: async (input) => {
|
|
1663
|
+
const rows = await sql<{ id: string }[]>`
|
|
1664
|
+
UPDATE ai.conversations
|
|
1665
|
+
SET archived_at = now(), pinned_at = NULL
|
|
1666
|
+
WHERE id = ${input.conversationId}
|
|
1667
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1668
|
+
AND archived_at IS NULL
|
|
1669
|
+
AND NOT EXISTS (
|
|
1670
|
+
SELECT 1
|
|
1671
|
+
FROM ai.turns
|
|
1672
|
+
WHERE conversation_id = ai.conversations.id
|
|
1673
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1674
|
+
)
|
|
1675
|
+
RETURNING id
|
|
1676
|
+
`;
|
|
1677
|
+
return Boolean(rows[0]);
|
|
1678
|
+
},
|
|
1679
|
+
|
|
1680
|
+
restoreConversation: async (input) => {
|
|
1681
|
+
const rows = await sql<{ id: string }[]>`
|
|
1682
|
+
UPDATE ai.conversations
|
|
1683
|
+
SET archived_at = NULL
|
|
1684
|
+
WHERE id = ${input.conversationId}
|
|
1685
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1686
|
+
AND archived_at IS NOT NULL
|
|
1687
|
+
RETURNING id
|
|
1688
|
+
`;
|
|
1689
|
+
return rows[0] ? loadConversationSummary(input) : null;
|
|
1690
|
+
},
|
|
1691
|
+
|
|
1692
|
+
markConversationViewed: async (input) => {
|
|
1693
|
+
const rows = await sql<{ id: string }[]>`
|
|
1694
|
+
UPDATE ai.conversations
|
|
1695
|
+
SET last_viewed_at = now()
|
|
1696
|
+
WHERE id = ${input.conversationId}
|
|
1697
|
+
AND (${input.ownerUserId ?? null}::uuid IS NULL OR created_by_user_id = ${input.ownerUserId ?? null})
|
|
1698
|
+
AND archived_at IS NULL
|
|
1699
|
+
RETURNING id
|
|
1700
|
+
`;
|
|
1701
|
+
return Boolean(rows[0]);
|
|
1702
|
+
},
|
|
1703
|
+
|
|
1704
|
+
listEnrichmentCandidates: async (input) => {
|
|
1705
|
+
const limit = Math.min(Math.max(input.limit, 1), 100);
|
|
1706
|
+
const onlyId = input.conversationId ?? null;
|
|
1707
|
+
// dirty_as_of carries updated_at at full microsecond precision (::text
|
|
1708
|
+
// round-trips losslessly); the ISO field is millisecond-truncated and
|
|
1709
|
+
// must never be written back as enriched_at.
|
|
1710
|
+
// Failure backoff: 5min * 2^fail_count, capped at 2^7 (~10.7h).
|
|
1711
|
+
// A manual reindex (conversationId set) skips the dirty and backoff checks.
|
|
1712
|
+
const rows = await sql<(ConversationRow & { dirty_as_of: string })[]>`
|
|
1713
|
+
SELECT c.*, c.updated_at::text AS dirty_as_of
|
|
1714
|
+
FROM ai.conversations c
|
|
1715
|
+
WHERE c.archived_at IS NULL
|
|
1716
|
+
AND (${onlyId}::uuid IS NULL OR c.id = ${onlyId}::uuid)
|
|
1717
|
+
AND (
|
|
1718
|
+
${onlyId}::uuid IS NOT NULL
|
|
1719
|
+
OR (
|
|
1720
|
+
(c.enriched_at IS NULL OR c.updated_at > c.enriched_at)
|
|
1721
|
+
AND (
|
|
1722
|
+
c.enrich_failed_at IS NULL
|
|
1723
|
+
OR c.enrich_failed_at + (interval '5 minutes' * pow(2, LEAST(c.enrich_fail_count, 7))) < now()
|
|
1724
|
+
)
|
|
1725
|
+
)
|
|
1726
|
+
)
|
|
1727
|
+
AND EXISTS (SELECT 1 FROM ai.messages m WHERE m.conversation_id = c.id)
|
|
1728
|
+
AND NOT EXISTS (
|
|
1729
|
+
SELECT 1 FROM ai.turns t
|
|
1730
|
+
WHERE t.conversation_id = c.id AND t.status IN ('queued', 'running', 'waiting_for_action')
|
|
1731
|
+
)
|
|
1732
|
+
ORDER BY c.updated_at ASC
|
|
1733
|
+
LIMIT ${limit}
|
|
1734
|
+
`;
|
|
1735
|
+
return rows.map((row) => ({
|
|
1736
|
+
...rowToConversation(row),
|
|
1737
|
+
dirtyAsOf: row.dirty_as_of,
|
|
1738
|
+
enrichFailCount: row.enrich_fail_count ?? 0,
|
|
1739
|
+
}));
|
|
1740
|
+
},
|
|
1741
|
+
|
|
1742
|
+
applyEnrichment: async (input) => {
|
|
1743
|
+
const title = input.title?.trim();
|
|
1744
|
+
const description = input.description?.trim();
|
|
1745
|
+
await sql`
|
|
1746
|
+
UPDATE ai.conversations
|
|
1747
|
+
SET keywords = ${toPgTextArray(input.keywords)}::text[],
|
|
1748
|
+
search_summary = ${input.searchSummary.trim()},
|
|
1749
|
+
title = COALESCE(${title ?? null}, title),
|
|
1750
|
+
title_source = CASE WHEN ${title ?? null}::text IS NOT NULL THEN 'auto' ELSE title_source END,
|
|
1751
|
+
description = COALESCE(${description ?? null}, description),
|
|
1752
|
+
description_source = CASE WHEN ${description ?? null}::text IS NOT NULL THEN 'auto' ELSE description_source END,
|
|
1753
|
+
enriched_at = ${input.dirtyAsOf}::timestamptz,
|
|
1754
|
+
enrich_failed_at = NULL,
|
|
1755
|
+
enrich_fail_count = 0
|
|
1756
|
+
WHERE id = ${input.conversationId}
|
|
1757
|
+
`;
|
|
1758
|
+
},
|
|
1759
|
+
|
|
1760
|
+
markEnrichmentFailed: async (input) => {
|
|
1761
|
+
await sql`
|
|
1762
|
+
UPDATE ai.conversations
|
|
1763
|
+
SET enrich_failed_at = now(), enrich_fail_count = enrich_fail_count + 1
|
|
1764
|
+
WHERE id = ${input.conversationId}
|
|
1765
|
+
`;
|
|
1766
|
+
},
|
|
1767
|
+
|
|
1768
|
+
recordEnrichmentRun: async (input) => {
|
|
1769
|
+
await sql`
|
|
1770
|
+
INSERT INTO ai.enrichment_runs (conversation_id, status, trigger, model_profile_id, mode, duration_ms, title_updated, keywords_count, error)
|
|
1771
|
+
VALUES (
|
|
1772
|
+
${input.conversationId},
|
|
1773
|
+
${input.status},
|
|
1774
|
+
${input.trigger},
|
|
1775
|
+
${input.modelProfileId ?? null},
|
|
1776
|
+
${input.mode ?? null},
|
|
1777
|
+
${input.durationMs ?? null},
|
|
1778
|
+
${input.titleUpdated ?? false},
|
|
1779
|
+
${input.keywordsCount ?? 0},
|
|
1780
|
+
${input.error?.slice(0, 500) ?? null}
|
|
1781
|
+
)
|
|
1782
|
+
`;
|
|
1783
|
+
// Retention: keep the newest 20 runs per conversation.
|
|
1784
|
+
await sql`
|
|
1785
|
+
DELETE FROM ai.enrichment_runs
|
|
1786
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1787
|
+
AND id NOT IN (
|
|
1788
|
+
SELECT id FROM ai.enrichment_runs
|
|
1789
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1790
|
+
ORDER BY created_at DESC
|
|
1791
|
+
LIMIT 20
|
|
1792
|
+
)
|
|
1793
|
+
`;
|
|
1794
|
+
},
|
|
1795
|
+
|
|
1796
|
+
listEnrichmentRuns: async (input) => {
|
|
1797
|
+
const limit = Math.min(Math.max(input.limit ?? 20, 1), 50);
|
|
1798
|
+
const rows = await sql<EnrichmentRunRow[]>`
|
|
1799
|
+
SELECT * FROM ai.enrichment_runs
|
|
1800
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1801
|
+
ORDER BY created_at DESC
|
|
1802
|
+
LIMIT ${limit}
|
|
1803
|
+
`;
|
|
1804
|
+
return rows.map(rowToEnrichmentRun);
|
|
1805
|
+
},
|
|
1806
|
+
|
|
1807
|
+
getEnrichmentStatus: async (input) => {
|
|
1808
|
+
const rows = await sql<
|
|
1809
|
+
{ enriched_at: Date | string | null; dirty: boolean; enrich_fail_count: number | null; keywords: string[] | null }[]
|
|
1810
|
+
>`
|
|
1811
|
+
SELECT enriched_at, (enriched_at IS NULL OR updated_at > enriched_at) AS dirty, enrich_fail_count, keywords
|
|
1812
|
+
FROM ai.conversations
|
|
1813
|
+
WHERE id = ${input.conversationId}
|
|
1814
|
+
`;
|
|
1815
|
+
const row = rows[0];
|
|
1816
|
+
if (!row) return null;
|
|
1817
|
+
return {
|
|
1818
|
+
enrichedAt: row.enriched_at ? iso(row.enriched_at) : null,
|
|
1819
|
+
dirty: row.dirty,
|
|
1820
|
+
enrichFailCount: row.enrich_fail_count ?? 0,
|
|
1821
|
+
keywords: row.keywords ?? [],
|
|
1822
|
+
};
|
|
1823
|
+
},
|
|
1824
|
+
|
|
1825
|
+
getEnrichmentOverview: async (): Promise<AiEnrichmentOverview> => {
|
|
1826
|
+
const [summary] = await sql<
|
|
1827
|
+
Array<{
|
|
1828
|
+
total_conversations: number | string;
|
|
1829
|
+
dirty_conversations: number | string;
|
|
1830
|
+
failed_conversations: number | string;
|
|
1831
|
+
oldest_dirty_at: Date | string | null;
|
|
1832
|
+
last_run_at: Date | string | null;
|
|
1833
|
+
avg_duration_ms: number | string | null;
|
|
1834
|
+
failed_runs_24h: number | string;
|
|
1835
|
+
total_runs_24h: number | string;
|
|
1836
|
+
}>
|
|
1837
|
+
>`
|
|
1838
|
+
WITH conversation_summary AS (
|
|
1839
|
+
SELECT
|
|
1840
|
+
count(*)::int AS total_conversations,
|
|
1841
|
+
count(*) FILTER (WHERE enriched_at IS NULL OR updated_at > enriched_at)::int AS dirty_conversations,
|
|
1842
|
+
count(*) FILTER (WHERE enrich_fail_count > 0)::int AS failed_conversations,
|
|
1843
|
+
min(updated_at) FILTER (WHERE enriched_at IS NULL OR updated_at > enriched_at) AS oldest_dirty_at
|
|
1844
|
+
FROM ai.conversations
|
|
1845
|
+
WHERE archived_at IS NULL
|
|
1846
|
+
),
|
|
1847
|
+
run_summary AS (
|
|
1848
|
+
SELECT
|
|
1849
|
+
max(created_at) AS last_run_at,
|
|
1850
|
+
round(avg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL))::int AS avg_duration_ms,
|
|
1851
|
+
count(*) FILTER (WHERE created_at >= now() - interval '24 hours')::int AS total_runs_24h,
|
|
1852
|
+
count(*) FILTER (WHERE status = 'failed' AND created_at >= now() - interval '24 hours')::int AS failed_runs_24h
|
|
1853
|
+
FROM ai.enrichment_runs
|
|
1854
|
+
)
|
|
1855
|
+
SELECT *
|
|
1856
|
+
FROM conversation_summary
|
|
1857
|
+
CROSS JOIN run_summary
|
|
1858
|
+
`;
|
|
1859
|
+
const recentRows = await sql<EnrichmentRunRow[]>`
|
|
1860
|
+
SELECT r.*, c.title AS conversation_title
|
|
1861
|
+
FROM ai.enrichment_runs r
|
|
1862
|
+
JOIN ai.conversations c ON c.id = r.conversation_id
|
|
1863
|
+
WHERE c.archived_at IS NULL
|
|
1864
|
+
ORDER BY r.created_at DESC
|
|
1865
|
+
LIMIT 8
|
|
1866
|
+
`;
|
|
1867
|
+
const totalRuns24h = intValue(summary?.total_runs_24h);
|
|
1868
|
+
const failedRuns24h = intValue(summary?.failed_runs_24h);
|
|
1869
|
+
return {
|
|
1870
|
+
totalConversations: intValue(summary?.total_conversations),
|
|
1871
|
+
dirtyConversations: intValue(summary?.dirty_conversations),
|
|
1872
|
+
failedConversations: intValue(summary?.failed_conversations),
|
|
1873
|
+
oldestDirtyAt: summary?.oldest_dirty_at ? iso(summary.oldest_dirty_at) : null,
|
|
1874
|
+
lastRunAt: summary?.last_run_at ? iso(summary.last_run_at) : null,
|
|
1875
|
+
avgDurationMs: numberOrNull(summary?.avg_duration_ms),
|
|
1876
|
+
failedRuns24h,
|
|
1877
|
+
totalRuns24h,
|
|
1878
|
+
errorRate24h: totalRuns24h > 0 ? (failedRuns24h / totalRuns24h) * 100 : 0,
|
|
1879
|
+
recentRuns: recentRows.map(rowToEnrichmentOverviewRun),
|
|
1880
|
+
};
|
|
1881
|
+
},
|
|
1882
|
+
|
|
1883
|
+
listMessages: async (input) => {
|
|
1884
|
+
// Human view: compacted messages stay visible; superseded (compacted)
|
|
1885
|
+
// summaries are hidden. The active summary sorts after archived rows
|
|
1886
|
+
// sharing its checkpoint seq, marking where the model context begins.
|
|
1887
|
+
const rows = await sql<MessageRow[]>`
|
|
1888
|
+
SELECT *
|
|
1889
|
+
FROM ai.messages
|
|
1890
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1891
|
+
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
1892
|
+
ORDER BY seq ASC, (kind = 'summary')::int ASC
|
|
1893
|
+
`;
|
|
1894
|
+
return rows.map(rowToMessage);
|
|
1895
|
+
},
|
|
1896
|
+
|
|
1897
|
+
listMessagesPage: async (input) => {
|
|
1898
|
+
const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
|
|
1899
|
+
const beforeSeq = Number.isFinite(input.beforeSeq) ? (input.beforeSeq ?? null) : null;
|
|
1900
|
+
// Window by DISTINCT seq: a seq group (archived rows + their compaction
|
|
1901
|
+
// summary share one seq) is never split, so `beforeSeq = min(seq)` is a
|
|
1902
|
+
// lossless cursor. Same visibility rules as listMessages.
|
|
1903
|
+
const rows = await sql<MessageRow[]>`
|
|
1904
|
+
WITH page_seqs AS (
|
|
1905
|
+
SELECT DISTINCT seq
|
|
1906
|
+
FROM ai.messages
|
|
1907
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1908
|
+
AND (${beforeSeq}::int IS NULL OR seq < ${beforeSeq})
|
|
1909
|
+
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
1910
|
+
ORDER BY seq DESC
|
|
1911
|
+
LIMIT ${limit}
|
|
1912
|
+
)
|
|
1913
|
+
SELECT *
|
|
1914
|
+
FROM ai.messages
|
|
1915
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1916
|
+
AND seq IN (SELECT seq FROM page_seqs)
|
|
1917
|
+
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
1918
|
+
ORDER BY seq ASC, (kind = 'summary')::int ASC
|
|
1919
|
+
`;
|
|
1920
|
+
const messages = rows.map(rowToMessage);
|
|
1921
|
+
const oldestSeq = messages[0]?.seq;
|
|
1922
|
+
if (oldestSeq === undefined) return { messages, hasMore: false };
|
|
1923
|
+
const older = await sql<{ exists: boolean }[]>`
|
|
1924
|
+
SELECT EXISTS (
|
|
1925
|
+
SELECT 1 FROM ai.messages
|
|
1926
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1927
|
+
AND seq < ${oldestSeq}
|
|
1928
|
+
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
1929
|
+
) AS exists
|
|
1930
|
+
`;
|
|
1931
|
+
return { messages, hasMore: Boolean(older[0]?.exists) };
|
|
1932
|
+
},
|
|
1933
|
+
|
|
1934
|
+
searchConversationMessages: async (input) => {
|
|
1935
|
+
const query = input.query.trim();
|
|
1936
|
+
if (!query) return { messages: [] };
|
|
1937
|
+
const beforeSeq = input.beforeSeq && input.beforeSeq > 0 ? Math.floor(input.beforeSeq) : null;
|
|
1938
|
+
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1939
|
+
const pattern = `%${query.toLowerCase()}%`;
|
|
1940
|
+
const rows = await sql<(MessageRow & { page_rank: number | string })[]>`
|
|
1941
|
+
WITH matching AS (
|
|
1942
|
+
SELECT *, DENSE_RANK() OVER (ORDER BY seq DESC) AS page_rank
|
|
1943
|
+
FROM ai.messages
|
|
1944
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1945
|
+
AND role <> 'tool_result'
|
|
1946
|
+
AND search_text <> ''
|
|
1947
|
+
AND NOT (kind = 'summary' AND compacted_at IS NOT NULL)
|
|
1948
|
+
AND (${beforeSeq}::int IS NULL OR seq < ${beforeSeq})
|
|
1949
|
+
AND (LOWER(search_text) LIKE ${pattern} OR search_document @@ websearch_to_tsquery('simple', ${query}))
|
|
1950
|
+
)
|
|
1951
|
+
SELECT *
|
|
1952
|
+
FROM matching
|
|
1953
|
+
WHERE page_rank <= ${limit + 1}
|
|
1954
|
+
ORDER BY seq DESC, (kind = 'summary')::int ASC, created_at DESC, id DESC
|
|
1955
|
+
`;
|
|
1956
|
+
const page = rows.filter((row) => Number(row.page_rank) <= limit);
|
|
1957
|
+
return {
|
|
1958
|
+
messages: page.map(rowToMessage),
|
|
1959
|
+
...(rows.some((row) => Number(row.page_rank) > limit) && page.at(-1) ? { nextCursor: String(page.at(-1)!.seq) } : {}),
|
|
1960
|
+
};
|
|
1961
|
+
},
|
|
1962
|
+
|
|
1963
|
+
listConversationTimeline: async (input): Promise<AiConversationTimelineEntry[]> => {
|
|
1964
|
+
const rows = await sql<TimelineRow[]>`
|
|
1965
|
+
WITH normalized AS (
|
|
1966
|
+
SELECT
|
|
1967
|
+
id,
|
|
1968
|
+
short_id,
|
|
1969
|
+
seq,
|
|
1970
|
+
role,
|
|
1971
|
+
loop_id,
|
|
1972
|
+
created_at,
|
|
1973
|
+
CASE
|
|
1974
|
+
WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb
|
|
1975
|
+
ELSE message
|
|
1976
|
+
END AS payload,
|
|
1977
|
+
CASE
|
|
1978
|
+
WHEN meta IS NULL THEN NULL
|
|
1979
|
+
WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb
|
|
1980
|
+
ELSE meta
|
|
1981
|
+
END AS meta_payload
|
|
1982
|
+
FROM ai.messages
|
|
1983
|
+
WHERE conversation_id = ${input.conversationId}
|
|
1984
|
+
AND kind = 'message'
|
|
1985
|
+
AND role IN ('user', 'assistant')
|
|
1986
|
+
),
|
|
1987
|
+
message_text AS (
|
|
1988
|
+
SELECT
|
|
1989
|
+
normalized.*,
|
|
1990
|
+
COALESCE((
|
|
1991
|
+
SELECT string_agg(part ->> 'text', '' ORDER BY ordinal)
|
|
1992
|
+
FROM jsonb_array_elements(COALESCE(payload -> 'content', '[]'::jsonb)) WITH ORDINALITY AS content(part, ordinal)
|
|
1993
|
+
WHERE part ->> 'type' = 'text'
|
|
1994
|
+
AND NOT starts_with(COALESCE(part ->> 'text', ''), 'Attached files for this message:')
|
|
1995
|
+
), '') AS visible_text,
|
|
1996
|
+
COALESCE((
|
|
1997
|
+
SELECT count(*) FILTER (WHERE part ->> 'type' = 'file')
|
|
1998
|
+
+ sum(regexp_count(COALESCE(part ->> 'text', ''), '<attachment path='))
|
|
1999
|
+
+ sum(regexp_count(COALESCE(part ->> 'text', ''), '--- file: '))
|
|
2000
|
+
FROM jsonb_array_elements(COALESCE(payload -> 'content', '[]'::jsonb)) AS content(part)
|
|
2001
|
+
), 0) AS input_file_count
|
|
2002
|
+
FROM normalized
|
|
2003
|
+
),
|
|
2004
|
+
user_rows AS (
|
|
2005
|
+
SELECT
|
|
2006
|
+
message_text.*,
|
|
2007
|
+
lead(seq) OVER (ORDER BY seq ASC) AS next_user_seq
|
|
2008
|
+
FROM message_text
|
|
2009
|
+
WHERE role = 'user'
|
|
2010
|
+
),
|
|
2011
|
+
tool_summary AS (
|
|
2012
|
+
SELECT
|
|
2013
|
+
turn_id::text AS loop_id,
|
|
2014
|
+
count(*)::int AS tool_count,
|
|
2015
|
+
count(*) FILTER (WHERE tool_name = 'present' AND status = 'completed')::int AS output_file_count
|
|
2016
|
+
FROM ai.tool_calls
|
|
2017
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2018
|
+
GROUP BY turn_id
|
|
2019
|
+
)
|
|
2020
|
+
SELECT
|
|
2021
|
+
users.short_id AS id,
|
|
2022
|
+
users.seq,
|
|
2023
|
+
COALESCE(timeline_turn.short_id, users.loop_id) AS loop_id,
|
|
2024
|
+
left(
|
|
2025
|
+
regexp_replace(
|
|
2026
|
+
regexp_replace(users.visible_text, '<attachment path="[^"]+" media-type="[^"]*" size="[0-9]+" />', '', 'g'),
|
|
2027
|
+
'\\s+',
|
|
2028
|
+
' ',
|
|
2029
|
+
'g'
|
|
2030
|
+
),
|
|
2031
|
+
240
|
|
2032
|
+
) AS user_preview,
|
|
2033
|
+
left(COALESCE((
|
|
2034
|
+
SELECT string_agg(regexp_replace(assistant.visible_text, '\\s+', ' ', 'g'), ' ' ORDER BY assistant.seq)
|
|
2035
|
+
FROM message_text assistant
|
|
2036
|
+
WHERE assistant.role = 'assistant'
|
|
2037
|
+
AND assistant.seq > users.seq
|
|
2038
|
+
AND (users.next_user_seq IS NULL OR assistant.seq < users.next_user_seq)
|
|
2039
|
+
), ''), 320) AS assistant_preview,
|
|
2040
|
+
COALESCE(users.meta_payload ? 'steerId', false) AS is_steer,
|
|
2041
|
+
users.input_file_count,
|
|
2042
|
+
COALESCE(tools.output_file_count, 0) AS output_file_count,
|
|
2043
|
+
COALESCE(tools.tool_count, 0) AS tool_count,
|
|
2044
|
+
users.created_at
|
|
2045
|
+
FROM user_rows users
|
|
2046
|
+
LEFT JOIN tool_summary tools ON tools.loop_id = users.loop_id
|
|
2047
|
+
LEFT JOIN ai.turns timeline_turn ON timeline_turn.id::text = users.loop_id
|
|
2048
|
+
ORDER BY users.seq ASC
|
|
2049
|
+
`;
|
|
2050
|
+
return rows.map((row) => ({
|
|
2051
|
+
id: row.id,
|
|
2052
|
+
seq: Number(row.seq),
|
|
2053
|
+
loopId: row.loop_id,
|
|
2054
|
+
userPreview: row.user_preview?.trim() || "Message",
|
|
2055
|
+
assistantPreview: row.assistant_preview?.trim() || "",
|
|
2056
|
+
isSteer: Boolean(row.is_steer),
|
|
2057
|
+
inputFileCount: Number(row.input_file_count ?? 0),
|
|
2058
|
+
outputFileCount: Number(row.output_file_count ?? 0),
|
|
2059
|
+
toolCount: Number(row.tool_count ?? 0),
|
|
2060
|
+
createdAt: iso(row.created_at),
|
|
2061
|
+
}));
|
|
2062
|
+
},
|
|
2063
|
+
|
|
2064
|
+
listContextMessages: async (input) => {
|
|
2065
|
+
const rows = await sql<MessageRow[]>`
|
|
2066
|
+
SELECT *
|
|
2067
|
+
FROM ai.messages
|
|
2068
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2069
|
+
AND compacted_at IS NULL
|
|
2070
|
+
ORDER BY seq ASC
|
|
2071
|
+
`;
|
|
2072
|
+
return rows.map(rowToMessage);
|
|
2073
|
+
},
|
|
2074
|
+
|
|
2075
|
+
listTurnMessages: async (input) => {
|
|
2076
|
+
const rows = await sql<MessageRow[]>`
|
|
2077
|
+
SELECT *
|
|
2078
|
+
FROM ai.messages
|
|
2079
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2080
|
+
AND loop_id = ${input.loopId}
|
|
2081
|
+
AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
|
|
2082
|
+
ORDER BY seq ASC
|
|
2083
|
+
`;
|
|
2084
|
+
return rows.map(rowToMessage);
|
|
2085
|
+
},
|
|
2086
|
+
|
|
2087
|
+
setMessageFeedback: async (input) => {
|
|
2088
|
+
const reasons = input.feedback.rating === "down" ? input.feedback.reasons : [];
|
|
2089
|
+
const comment = input.feedback.comment?.trim() || null;
|
|
2090
|
+
const rows = await sql<MessageRow[]>`
|
|
2091
|
+
UPDATE ai.messages
|
|
2092
|
+
SET feedback_rating = ${input.feedback.rating === "up" ? 1 : -1},
|
|
2093
|
+
feedback_reasons = ${toPgTextArray(reasons)}::text[],
|
|
2094
|
+
feedback_comment = ${comment},
|
|
2095
|
+
feedback_updated_at = now()
|
|
2096
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
2097
|
+
AND short_id = ${input.messageShortId}
|
|
2098
|
+
AND kind = 'message'
|
|
2099
|
+
AND role = 'assistant'
|
|
2100
|
+
RETURNING *
|
|
2101
|
+
`;
|
|
2102
|
+
return rows[0] ? (rowToMessage(rows[0]).feedback ?? null) : null;
|
|
2103
|
+
},
|
|
2104
|
+
|
|
2105
|
+
clearMessageFeedback: async (input) => {
|
|
2106
|
+
const rows = await sql<{ id: string }[]>`
|
|
2107
|
+
UPDATE ai.messages
|
|
2108
|
+
SET feedback_rating = NULL,
|
|
2109
|
+
feedback_reasons = '{}'::text[],
|
|
2110
|
+
feedback_comment = NULL,
|
|
2111
|
+
feedback_updated_at = NULL
|
|
2112
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
2113
|
+
AND short_id = ${input.messageShortId}
|
|
2114
|
+
AND kind = 'message'
|
|
2115
|
+
AND role = 'assistant'
|
|
2116
|
+
RETURNING id
|
|
2117
|
+
`;
|
|
2118
|
+
return Boolean(rows[0]);
|
|
2119
|
+
},
|
|
2120
|
+
|
|
2121
|
+
copyMessages: async (input) => {
|
|
2122
|
+
const throughSeq = Math.floor(input.throughSeq);
|
|
2123
|
+
if (!Number.isFinite(throughSeq) || throughSeq <= 0) return;
|
|
2124
|
+
|
|
2125
|
+
await sql.begin(async (tx) => {
|
|
2126
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${input.targetConversationId} FOR UPDATE`;
|
|
2127
|
+
await tx`
|
|
2128
|
+
INSERT INTO ai.messages (
|
|
2129
|
+
short_id,
|
|
2130
|
+
conversation_id,
|
|
2131
|
+
seq,
|
|
2132
|
+
kind,
|
|
2133
|
+
role,
|
|
2134
|
+
message,
|
|
2135
|
+
search_text,
|
|
2136
|
+
loop_id,
|
|
2137
|
+
model_profile_id,
|
|
2138
|
+
provider_model,
|
|
2139
|
+
usage,
|
|
2140
|
+
stop_reason,
|
|
2141
|
+
loop_aggregate,
|
|
2142
|
+
loop_done_reason
|
|
2143
|
+
)
|
|
2144
|
+
SELECT
|
|
2145
|
+
short_id,
|
|
2146
|
+
${input.targetConversationId},
|
|
2147
|
+
seq,
|
|
2148
|
+
kind,
|
|
2149
|
+
role,
|
|
2150
|
+
message,
|
|
2151
|
+
search_text,
|
|
2152
|
+
loop_id,
|
|
2153
|
+
model_profile_id,
|
|
2154
|
+
provider_model,
|
|
2155
|
+
usage,
|
|
2156
|
+
stop_reason,
|
|
2157
|
+
loop_aggregate,
|
|
2158
|
+
loop_done_reason
|
|
2159
|
+
FROM ai.messages
|
|
2160
|
+
WHERE conversation_id = ${input.sourceConversationId}
|
|
2161
|
+
AND compacted_at IS NULL
|
|
2162
|
+
AND seq <= ${throughSeq}
|
|
2163
|
+
ORDER BY seq ASC
|
|
2164
|
+
`;
|
|
2165
|
+
await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.targetConversationId}`;
|
|
2166
|
+
});
|
|
2167
|
+
},
|
|
2168
|
+
|
|
2169
|
+
truncateMessagesFrom: async (input) => {
|
|
2170
|
+
const fromSeq = Math.floor(input.fromSeq);
|
|
2171
|
+
if (!Number.isFinite(fromSeq) || fromSeq <= 0) return;
|
|
2172
|
+
|
|
2173
|
+
await sql.begin(async (tx) => {
|
|
2174
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
2175
|
+
await tx`
|
|
2176
|
+
DELETE FROM ai.messages
|
|
2177
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2178
|
+
AND compacted_at IS NULL
|
|
2179
|
+
AND seq >= ${fromSeq}
|
|
2180
|
+
`;
|
|
2181
|
+
await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
|
|
2182
|
+
});
|
|
2183
|
+
},
|
|
2184
|
+
|
|
2185
|
+
setLatestAssistantLoopAggregate: async (input) => {
|
|
2186
|
+
const loopId = input.loopId ?? null;
|
|
2187
|
+
await sql`
|
|
2188
|
+
UPDATE ai.messages
|
|
2189
|
+
SET loop_aggregate = ${JSON.stringify(input.aggregate)}::jsonb,
|
|
2190
|
+
loop_done_reason = ${input.doneReason}
|
|
2191
|
+
WHERE id = (
|
|
2192
|
+
SELECT id
|
|
2193
|
+
FROM ai.messages
|
|
2194
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2195
|
+
AND compacted_at IS NULL
|
|
2196
|
+
AND kind = 'message'
|
|
2197
|
+
AND role = 'assistant'
|
|
2198
|
+
AND (${loopId}::text IS NULL OR loop_id = ${loopId})
|
|
2199
|
+
ORDER BY seq DESC
|
|
2200
|
+
LIMIT 1
|
|
2201
|
+
)
|
|
2202
|
+
`;
|
|
2203
|
+
},
|
|
2204
|
+
|
|
2205
|
+
compactMessages: async (input) => {
|
|
2206
|
+
const checkpointSeq = Math.floor(input.checkpointSeq);
|
|
2207
|
+
if (!Number.isFinite(checkpointSeq) || checkpointSeq <= 0) return;
|
|
2208
|
+
|
|
2209
|
+
await sql.begin(async (tx) => {
|
|
2210
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
2211
|
+
const rows = await tx<{ count: number }[]>`
|
|
2212
|
+
SELECT COUNT(*)::int AS count
|
|
2213
|
+
FROM ai.messages
|
|
2214
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2215
|
+
AND compacted_at IS NULL
|
|
2216
|
+
AND seq <= ${checkpointSeq}
|
|
2217
|
+
`;
|
|
2218
|
+
if ((rows[0]?.count ?? 0) === 0) return;
|
|
2219
|
+
|
|
2220
|
+
const archived = await tx<{ count: number }[]>`
|
|
2221
|
+
WITH archived AS (
|
|
2222
|
+
UPDATE ai.messages
|
|
2223
|
+
SET compacted_at = now()
|
|
2224
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2225
|
+
AND compacted_at IS NULL
|
|
2226
|
+
AND seq <= ${checkpointSeq}
|
|
2227
|
+
RETURNING id
|
|
2228
|
+
)
|
|
2229
|
+
SELECT COUNT(*)::int AS count FROM archived
|
|
2230
|
+
`;
|
|
2231
|
+
|
|
2232
|
+
await insertMessageLocked(
|
|
2233
|
+
{
|
|
2234
|
+
conversationId: input.conversationId,
|
|
2235
|
+
message: input.summary,
|
|
2236
|
+
kind: "summary",
|
|
2237
|
+
seq: checkpointSeq,
|
|
2238
|
+
loopId: null,
|
|
2239
|
+
modelProfileId: input.modelProfileId ?? null,
|
|
2240
|
+
meta: { compactedCount: archived[0]?.count ?? 0 },
|
|
2241
|
+
},
|
|
2242
|
+
tx,
|
|
2243
|
+
);
|
|
2244
|
+
});
|
|
2245
|
+
},
|
|
2246
|
+
|
|
2247
|
+
createCompactionTurn: async (input) => {
|
|
2248
|
+
const rows = await withAiShortId(
|
|
2249
|
+
"idx_ai_turns_conversation_short_id",
|
|
2250
|
+
(shortId) => sql<TurnRow[]>`
|
|
2251
|
+
INSERT INTO ai.turns (
|
|
2252
|
+
short_id,
|
|
2253
|
+
conversation_id,
|
|
2254
|
+
model_profile_id,
|
|
2255
|
+
status,
|
|
2256
|
+
run_config
|
|
2257
|
+
)
|
|
2258
|
+
VALUES (
|
|
2259
|
+
${shortId},
|
|
2260
|
+
${input.conversationId},
|
|
2261
|
+
${input.modelProfileId},
|
|
2262
|
+
'queued',
|
|
2263
|
+
(${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb
|
|
2264
|
+
)
|
|
2265
|
+
RETURNING *
|
|
2266
|
+
`,
|
|
2267
|
+
);
|
|
2268
|
+
return rowToTurn(rows[0]!);
|
|
2269
|
+
},
|
|
2270
|
+
|
|
2271
|
+
submitChatTurn: async (input) => {
|
|
2272
|
+
return await sql.begin(async (tx) => {
|
|
2273
|
+
const [conversation] = await tx<{ draft_revision: number | string; project_id: string | null }[]>`
|
|
2274
|
+
SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE
|
|
2275
|
+
`;
|
|
2276
|
+
if (!conversation) throw new Error("Conversation not found.");
|
|
2277
|
+
if (input.expectedDraftRevision !== undefined && Number(conversation.draft_revision) !== input.expectedDraftRevision) {
|
|
2278
|
+
throw new Error("Conversation draft changed before the turn was submitted.");
|
|
2279
|
+
}
|
|
2280
|
+
if (input.expectedProjectId !== undefined && conversation.project_id !== input.expectedProjectId) {
|
|
2281
|
+
throw new Error("Conversation Project changed before the turn was submitted.");
|
|
2282
|
+
}
|
|
2283
|
+
if (typeof input.truncateFromSeq === "number" && input.truncateFromSeq > 0) {
|
|
2284
|
+
await tx`
|
|
2285
|
+
DELETE FROM ai.messages
|
|
2286
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2287
|
+
AND compacted_at IS NULL
|
|
2288
|
+
AND seq >= ${Math.floor(input.truncateFromSeq)}
|
|
2289
|
+
`;
|
|
2290
|
+
}
|
|
2291
|
+
const turnRows = await withAiShortIdForDb(
|
|
2292
|
+
tx,
|
|
2293
|
+
"idx_ai_turns_conversation_short_id",
|
|
2294
|
+
(attempt, shortId) => attempt<TurnRow[]>`
|
|
2295
|
+
INSERT INTO ai.turns (
|
|
2296
|
+
short_id,
|
|
2297
|
+
conversation_id,
|
|
2298
|
+
model_profile_id,
|
|
2299
|
+
status,
|
|
2300
|
+
run_config
|
|
2301
|
+
)
|
|
2302
|
+
VALUES (
|
|
2303
|
+
${shortId},
|
|
2304
|
+
${input.conversationId},
|
|
2305
|
+
${input.modelProfileId},
|
|
2306
|
+
'queued',
|
|
2307
|
+
(${JSON.stringify(input.runConfig)}::text)::jsonb
|
|
2308
|
+
)
|
|
2309
|
+
RETURNING *
|
|
2310
|
+
`,
|
|
2311
|
+
);
|
|
2312
|
+
const turn = rowToTurn(turnRows[0]!);
|
|
2313
|
+
const attachedFiles = input.runConfig.files?.attached ?? [];
|
|
2314
|
+
for (const file of attachedFiles) {
|
|
2315
|
+
const copied = input.retrySourceTurnId
|
|
2316
|
+
? await tx<{ path: string }[]>`
|
|
2317
|
+
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
|
|
2318
|
+
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
|
|
2319
|
+
FROM ai.turn_files
|
|
2320
|
+
WHERE turn_id = ${input.retrySourceTurnId}::uuid
|
|
2321
|
+
AND path = ${file.path}
|
|
2322
|
+
AND size = ${file.size}
|
|
2323
|
+
AND media_type = ${file.mediaType}
|
|
2324
|
+
AND version = ${file.version ?? -1}
|
|
2325
|
+
RETURNING path
|
|
2326
|
+
`
|
|
2327
|
+
: await tx<{ path: string }[]>`
|
|
2328
|
+
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
|
|
2329
|
+
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
|
|
2330
|
+
FROM ai.files
|
|
2331
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
2332
|
+
AND path = ${file.path}
|
|
2333
|
+
AND size = ${file.size}
|
|
2334
|
+
AND media_type = ${file.mediaType}
|
|
2335
|
+
AND version = ${file.version ?? -1}
|
|
2336
|
+
RETURNING path
|
|
2337
|
+
`;
|
|
2338
|
+
if (!copied[0]) throw new Error(`Attached conversation file changed before the turn was submitted: ${file.path}`);
|
|
2339
|
+
}
|
|
2340
|
+
const messageRow = await insertMessageLocked(
|
|
2341
|
+
{
|
|
2342
|
+
conversationId: input.conversationId,
|
|
2343
|
+
message: input.userMessage,
|
|
2344
|
+
loopId: turn.id,
|
|
2345
|
+
},
|
|
2346
|
+
tx,
|
|
2347
|
+
);
|
|
2348
|
+
const resources = [
|
|
2349
|
+
...new Map((input.resources ?? []).map((resource) => [`${resource.ref.type}\0${resource.ref.id}`, resource])).values(),
|
|
2350
|
+
];
|
|
2351
|
+
for (const resource of resources) {
|
|
2352
|
+
await tx`
|
|
2353
|
+
INSERT INTO ai.conversation_resource_refs (
|
|
2354
|
+
conversation_id, resource_type, resource_id, title, preview, icon, href, source_turn_id
|
|
2355
|
+
) VALUES (
|
|
2356
|
+
${input.conversationId}, ${resource.ref.type}, ${resource.ref.id}, ${resource.title ?? null},
|
|
2357
|
+
${resource.preview ?? null}, ${resource.icon ?? null}, ${resource.href ?? null}, ${turn.id}
|
|
2358
|
+
)
|
|
2359
|
+
ON CONFLICT (conversation_id, resource_type, resource_id)
|
|
2360
|
+
DO UPDATE SET
|
|
2361
|
+
title = COALESCE(EXCLUDED.title, ai.conversation_resource_refs.title),
|
|
2362
|
+
preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
|
|
2363
|
+
icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
|
|
2364
|
+
href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
|
|
2365
|
+
source_turn_id = EXCLUDED.source_turn_id,
|
|
2366
|
+
last_seen_at = now()
|
|
2367
|
+
`;
|
|
2368
|
+
}
|
|
2369
|
+
if (input.expectedDraftRevision !== undefined) {
|
|
2370
|
+
await tx`
|
|
2371
|
+
UPDATE ai.conversations
|
|
2372
|
+
SET draft_content = '[]'::jsonb,
|
|
2373
|
+
draft_revision = draft_revision + 1,
|
|
2374
|
+
draft_updated_at = now()
|
|
2375
|
+
WHERE id = ${input.conversationId}::uuid
|
|
2376
|
+
`;
|
|
2377
|
+
}
|
|
2378
|
+
return { turn, message: rowToMessage(messageRow) };
|
|
2379
|
+
});
|
|
2380
|
+
},
|
|
2381
|
+
|
|
2382
|
+
getTurnRunConfig: async (input) => {
|
|
2383
|
+
const rows = await sql<{ run_config: unknown }[]>`
|
|
2384
|
+
SELECT run_config
|
|
2385
|
+
FROM ai.turns
|
|
2386
|
+
WHERE id = ${input.turnId}::uuid
|
|
2387
|
+
AND conversation_id = ${input.conversationId}::uuid
|
|
2388
|
+
LIMIT 1
|
|
2389
|
+
`;
|
|
2390
|
+
return rows[0]?.run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null;
|
|
2391
|
+
},
|
|
2392
|
+
|
|
2393
|
+
getTurn: async (input) => {
|
|
2394
|
+
const rows = await sql<TurnRow[]>`
|
|
2395
|
+
SELECT *
|
|
2396
|
+
FROM ai.turns
|
|
2397
|
+
WHERE id = ${input.turnId}
|
|
2398
|
+
AND conversation_id = ${input.conversationId}
|
|
2399
|
+
LIMIT 1
|
|
2400
|
+
`;
|
|
2401
|
+
return rows[0] ? rowToTurn(rows[0]) : null;
|
|
2402
|
+
},
|
|
2403
|
+
|
|
2404
|
+
getTurnByShortId: async (input) => {
|
|
2405
|
+
const rows = await sql<TurnRow[]>`
|
|
2406
|
+
SELECT *
|
|
2407
|
+
FROM ai.turns
|
|
2408
|
+
WHERE short_id = ${input.shortId}
|
|
2409
|
+
AND conversation_id = ${input.conversationId}::uuid
|
|
2410
|
+
LIMIT 1
|
|
2411
|
+
`;
|
|
2412
|
+
return rows[0] ? rowToTurn(rows[0]) : null;
|
|
2413
|
+
},
|
|
2414
|
+
|
|
2415
|
+
getActiveTurn: async (input) => {
|
|
2416
|
+
const rows = await sql<TurnRow[]>`
|
|
2417
|
+
SELECT *
|
|
2418
|
+
FROM ai.turns
|
|
2419
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2420
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
2421
|
+
ORDER BY created_at DESC
|
|
2422
|
+
LIMIT 1
|
|
2423
|
+
`;
|
|
2424
|
+
if (!rows[0]) return null;
|
|
2425
|
+
return {
|
|
2426
|
+
turn: rowToTurn(rows[0]),
|
|
2427
|
+
liveBlocks: rowToLiveBlocks(rows[0]),
|
|
2428
|
+
liveSeq: Number(rows[0].live_seq ?? 0),
|
|
2429
|
+
};
|
|
2430
|
+
},
|
|
2431
|
+
|
|
2432
|
+
claimTurn: async (input) => {
|
|
2433
|
+
const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
|
|
2434
|
+
const runBudgetMs = boundedMs(input.runBudgetMs, 10 * 60_000, 10_000, 60 * 60_000);
|
|
2435
|
+
const maxAttempts = Math.max(1, Math.floor(input.maxAttempts));
|
|
2436
|
+
const rows = await sql<TurnRow[]>`
|
|
2437
|
+
UPDATE ai.turns
|
|
2438
|
+
SET attempt = attempt + 1,
|
|
2439
|
+
status = 'running',
|
|
2440
|
+
lease_owner = ${input.leaseOwner},
|
|
2441
|
+
lease_expires_at = now() + (${leaseMs} * interval '1 millisecond'),
|
|
2442
|
+
heartbeat_at = now(),
|
|
2443
|
+
deadline = CASE
|
|
2444
|
+
WHEN ${input.from} = 'waiting' THEN now() + (${runBudgetMs} * interval '1 millisecond')
|
|
2445
|
+
ELSE COALESCE(deadline, now() + (${runBudgetMs} * interval '1 millisecond'))
|
|
2446
|
+
END
|
|
2447
|
+
WHERE id = ${input.turnId}
|
|
2448
|
+
AND conversation_id = ${input.conversationId}
|
|
2449
|
+
AND cancel_requested_at IS NULL
|
|
2450
|
+
AND attempt - (
|
|
2451
|
+
SELECT COUNT(*)::int
|
|
2452
|
+
FROM ai.pending_actions
|
|
2453
|
+
WHERE turn_id = ${input.turnId}
|
|
2454
|
+
AND conversation_id = ${input.conversationId}
|
|
2455
|
+
AND status = 'resolved'
|
|
2456
|
+
) < ${maxAttempts}
|
|
2457
|
+
AND (
|
|
2458
|
+
(
|
|
2459
|
+
${input.from} = 'queue'
|
|
2460
|
+
AND (
|
|
2461
|
+
status = 'queued'
|
|
2462
|
+
OR (status = 'running' AND (lease_expires_at IS NULL OR lease_expires_at < now()))
|
|
2463
|
+
)
|
|
2464
|
+
)
|
|
2465
|
+
OR (
|
|
2466
|
+
${input.from} = 'waiting'
|
|
2467
|
+
AND status = 'waiting_for_action'
|
|
2468
|
+
AND EXISTS (
|
|
2469
|
+
SELECT 1
|
|
2470
|
+
FROM ai.pending_actions action
|
|
2471
|
+
WHERE action.turn_id = ${input.turnId}
|
|
2472
|
+
AND action.conversation_id = ${input.conversationId}
|
|
2473
|
+
AND action.status = 'resolved'
|
|
2474
|
+
AND EXISTS (
|
|
2475
|
+
SELECT 1
|
|
2476
|
+
FROM jsonb_array_elements(
|
|
2477
|
+
CASE WHEN jsonb_typeof(live_blocks) = 'array' THEN live_blocks ELSE '[]'::jsonb END
|
|
2478
|
+
) block
|
|
2479
|
+
WHERE block->>'kind' = 'tool'
|
|
2480
|
+
AND block->>'callId' = action.call_id
|
|
2481
|
+
AND block->>'status' IN ('awaiting_approval', 'awaiting_client')
|
|
2482
|
+
)
|
|
2483
|
+
)
|
|
2484
|
+
)
|
|
2485
|
+
)
|
|
2486
|
+
RETURNING *
|
|
2487
|
+
`;
|
|
2488
|
+
if (!rows[0]) return null;
|
|
2489
|
+
const claim: AiTurnClaim = {
|
|
2490
|
+
turn: rowToTurn(rows[0]),
|
|
2491
|
+
runConfig: rows[0].run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null,
|
|
2492
|
+
liveBlocks: rows[0].live_blocks ? rowToLiveBlocks(rows[0]) : null,
|
|
2493
|
+
liveSeq: Number(rows[0].live_seq ?? 0),
|
|
2494
|
+
};
|
|
2495
|
+
return claim;
|
|
2496
|
+
},
|
|
2497
|
+
|
|
2498
|
+
heartbeatTurn: async (input) => {
|
|
2499
|
+
const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
|
|
2500
|
+
const rows = await sql<{ id: string }[]>`
|
|
2501
|
+
UPDATE ai.turns
|
|
2502
|
+
SET heartbeat_at = now(),
|
|
2503
|
+
lease_expires_at = now() + (${leaseMs} * interval '1 millisecond')
|
|
2504
|
+
WHERE id = ${input.turnId}
|
|
2505
|
+
AND conversation_id = ${input.conversationId}
|
|
2506
|
+
AND lease_owner = ${input.leaseOwner}
|
|
2507
|
+
AND status = 'running'
|
|
2508
|
+
AND cancel_requested_at IS NULL
|
|
2509
|
+
AND (deadline IS NULL OR deadline > now())
|
|
2510
|
+
RETURNING id
|
|
2511
|
+
`;
|
|
2512
|
+
return Boolean(rows[0]);
|
|
2513
|
+
},
|
|
2514
|
+
|
|
2515
|
+
suspendTurn: async (input) => {
|
|
2516
|
+
const waitingBudgetMs = boundedMs(input.waitingBudgetMs, 24 * 60 * 60_000, 60_000, 7 * 24 * 60 * 60_000);
|
|
2517
|
+
const rows = await sql<{ id: string }[]>`
|
|
2518
|
+
UPDATE ai.turns
|
|
2519
|
+
SET status = 'waiting_for_action',
|
|
2520
|
+
lease_owner = NULL,
|
|
2521
|
+
lease_expires_at = NULL,
|
|
2522
|
+
heartbeat_at = now(),
|
|
2523
|
+
live_blocks = (${JSON.stringify(input.blocks)}::text)::jsonb,
|
|
2524
|
+
live_seq = ${input.seq},
|
|
2525
|
+
deadline = now() + (${waitingBudgetMs} * interval '1 millisecond')
|
|
2526
|
+
WHERE id = ${input.turnId}
|
|
2527
|
+
AND conversation_id = ${input.conversationId}
|
|
2528
|
+
AND status = 'running'
|
|
2529
|
+
AND lease_owner = ${input.leaseOwner}
|
|
2530
|
+
RETURNING id
|
|
2531
|
+
`;
|
|
2532
|
+
return Boolean(rows[0]);
|
|
2533
|
+
},
|
|
2534
|
+
|
|
2535
|
+
saveTurnLiveState: async (input) => {
|
|
2536
|
+
const rows = await sql<{ id: string }[]>`
|
|
2537
|
+
UPDATE ai.turns
|
|
2538
|
+
SET live_blocks = (${JSON.stringify(input.blocks)}::text)::jsonb,
|
|
2539
|
+
live_seq = ${input.seq}
|
|
2540
|
+
WHERE id = ${input.turnId}
|
|
2541
|
+
AND conversation_id = ${input.conversationId}
|
|
2542
|
+
AND status = 'running'
|
|
2543
|
+
AND lease_owner = ${input.leaseOwner}
|
|
2544
|
+
RETURNING id
|
|
2545
|
+
`;
|
|
2546
|
+
return Boolean(rows[0]);
|
|
2547
|
+
},
|
|
2548
|
+
|
|
2549
|
+
requestTurnAbort: async (input) => {
|
|
2550
|
+
const reason = input.reason ?? "user";
|
|
2551
|
+
const rows = await sql<TurnRow[]>`
|
|
2552
|
+
UPDATE ai.turns
|
|
2553
|
+
SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
|
|
2554
|
+
cancellation_reason = COALESCE(cancellation_reason, ${reason})
|
|
2555
|
+
WHERE id = ${input.turnId}
|
|
2556
|
+
AND conversation_id = ${input.conversationId}
|
|
2557
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
2558
|
+
RETURNING status, lease_owner, lease_expires_at, id, conversation_id, attempt, model_profile_id, created_at, completed_at, error
|
|
2559
|
+
`;
|
|
2560
|
+
if (!rows[0]) return { found: false };
|
|
2561
|
+
const row = rows[0];
|
|
2562
|
+
const ownerless = !row.lease_owner || !row.lease_expires_at || new Date(row.lease_expires_at).getTime() < Date.now();
|
|
2563
|
+
return { found: true, status: row.status, ownerless };
|
|
2564
|
+
},
|
|
2565
|
+
|
|
2566
|
+
completeTurn: async (input) => {
|
|
2567
|
+
return sql.begin(async (tx) => {
|
|
2568
|
+
const turnRows = await tx<{ id: string }[]>`
|
|
2569
|
+
SELECT id
|
|
2570
|
+
FROM ai.turns
|
|
2571
|
+
WHERE id = ${input.turnId}
|
|
2572
|
+
AND conversation_id = ${input.conversationId}
|
|
2573
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
2574
|
+
AND (
|
|
2575
|
+
(${input.leaseOwner ?? null}::text IS NOT NULL AND lease_owner = ${input.leaseOwner ?? null})
|
|
2576
|
+
OR (
|
|
2577
|
+
${input.leaseOwner ?? null}::text IS NULL
|
|
2578
|
+
AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
|
|
2579
|
+
)
|
|
2580
|
+
)
|
|
2581
|
+
FOR UPDATE
|
|
2582
|
+
`;
|
|
2583
|
+
if (!turnRows[0]) return "lost" as const;
|
|
2584
|
+
|
|
2585
|
+
if (input.status === "completed") {
|
|
2586
|
+
const pending = await tx<{ id: string }[]>`
|
|
2587
|
+
SELECT id
|
|
2588
|
+
FROM ai.turn_steers
|
|
2589
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2590
|
+
AND turn_id = ${input.turnId}
|
|
2591
|
+
AND status = 'pending'
|
|
2592
|
+
LIMIT 1
|
|
2593
|
+
`;
|
|
2594
|
+
if (pending[0]) return "pending_steering" as const;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
await tx`
|
|
2598
|
+
UPDATE ai.turns
|
|
2599
|
+
SET status = ${input.status},
|
|
2600
|
+
completed_at = now(),
|
|
2601
|
+
error = ${input.error ?? null},
|
|
2602
|
+
lease_owner = NULL,
|
|
2603
|
+
lease_expires_at = NULL,
|
|
2604
|
+
live_blocks = NULL
|
|
2605
|
+
WHERE id = ${input.turnId}
|
|
2606
|
+
`;
|
|
2607
|
+
await tx`
|
|
2608
|
+
UPDATE ai.pending_actions
|
|
2609
|
+
SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
|
|
2610
|
+
WHERE turn_id = ${input.turnId}
|
|
2611
|
+
AND status = 'pending'
|
|
2612
|
+
`;
|
|
2613
|
+
if (input.status !== "completed") {
|
|
2614
|
+
await tx`
|
|
2615
|
+
UPDATE ai.turn_steers
|
|
2616
|
+
SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
|
|
2617
|
+
WHERE turn_id = ${input.turnId}
|
|
2618
|
+
AND status = 'pending'
|
|
2619
|
+
`;
|
|
2620
|
+
}
|
|
2621
|
+
return "completed" as const;
|
|
2622
|
+
});
|
|
2623
|
+
},
|
|
2624
|
+
|
|
2625
|
+
sweepTurns: async (input) => {
|
|
2626
|
+
const limit = Math.min(Math.max(Math.floor(input?.limit ?? 200), 1), 1_000);
|
|
2627
|
+
const maxAttempts = Math.max(1, Math.floor(input?.maxAttempts ?? 5));
|
|
2628
|
+
const result: AiTurnSweepResult = { requeued: [], failed: [], aborted: [] };
|
|
2629
|
+
|
|
2630
|
+
// 1) Finalize turns that exhausted actual recovery attempts. Resuming a
|
|
2631
|
+
// resolved user/frontend action is normal progress and does not consume
|
|
2632
|
+
// this budget.
|
|
2633
|
+
const exhaustedRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
|
|
2634
|
+
UPDATE ai.turns
|
|
2635
|
+
SET status = 'failed',
|
|
2636
|
+
completed_at = now(),
|
|
2637
|
+
error = 'AI turn exhausted its recovery attempts.',
|
|
2638
|
+
lease_owner = NULL,
|
|
2639
|
+
lease_expires_at = NULL,
|
|
2640
|
+
live_blocks = NULL
|
|
2641
|
+
WHERE id IN (
|
|
2642
|
+
SELECT turn_row.id
|
|
2643
|
+
FROM ai.turns turn_row
|
|
2644
|
+
WHERE turn_row.cancel_requested_at IS NULL
|
|
2645
|
+
AND (turn_row.lease_owner IS NULL OR turn_row.lease_expires_at IS NULL OR turn_row.lease_expires_at < now())
|
|
2646
|
+
AND turn_row.attempt - (
|
|
2647
|
+
SELECT COUNT(*)::int
|
|
2648
|
+
FROM ai.pending_actions action
|
|
2649
|
+
WHERE action.turn_id = turn_row.id
|
|
2650
|
+
AND action.conversation_id = turn_row.conversation_id
|
|
2651
|
+
AND action.status = 'resolved'
|
|
2652
|
+
) >= ${maxAttempts}
|
|
2653
|
+
AND (
|
|
2654
|
+
turn_row.status = 'queued'
|
|
2655
|
+
OR (turn_row.status = 'running' AND (turn_row.lease_expires_at IS NULL OR turn_row.lease_expires_at < now()))
|
|
2656
|
+
OR (
|
|
2657
|
+
turn_row.status = 'waiting_for_action'
|
|
2658
|
+
AND EXISTS (
|
|
2659
|
+
SELECT 1
|
|
2660
|
+
FROM ai.pending_actions action
|
|
2661
|
+
WHERE action.turn_id = turn_row.id
|
|
2662
|
+
AND action.conversation_id = turn_row.conversation_id
|
|
2663
|
+
AND action.status = 'resolved'
|
|
2664
|
+
AND EXISTS (
|
|
2665
|
+
SELECT 1
|
|
2666
|
+
FROM jsonb_array_elements(
|
|
2667
|
+
CASE
|
|
2668
|
+
WHEN jsonb_typeof(turn_row.live_blocks) = 'array' THEN turn_row.live_blocks
|
|
2669
|
+
ELSE '[]'::jsonb
|
|
2670
|
+
END
|
|
2671
|
+
) block
|
|
2672
|
+
WHERE block->>'kind' = 'tool'
|
|
2673
|
+
AND block->>'callId' = action.call_id
|
|
2674
|
+
AND block->>'status' IN ('awaiting_approval', 'awaiting_client')
|
|
2675
|
+
)
|
|
2676
|
+
)
|
|
2677
|
+
)
|
|
2678
|
+
)
|
|
2679
|
+
LIMIT ${limit}
|
|
2680
|
+
)
|
|
2681
|
+
RETURNING id, conversation_id, error, attempt, live_seq
|
|
2682
|
+
`;
|
|
2683
|
+
|
|
2684
|
+
// 2) Finalize over-budget turns without a live lease.
|
|
2685
|
+
const budgetRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
|
|
2686
|
+
UPDATE ai.turns
|
|
2687
|
+
SET status = 'failed',
|
|
2688
|
+
completed_at = now(),
|
|
2689
|
+
error = 'AI turn exceeded its execution budget.',
|
|
2690
|
+
lease_owner = NULL,
|
|
2691
|
+
lease_expires_at = NULL,
|
|
2692
|
+
live_blocks = NULL
|
|
2693
|
+
WHERE id IN (
|
|
2694
|
+
SELECT id FROM ai.turns
|
|
2695
|
+
WHERE status IN ('queued', 'running')
|
|
2696
|
+
AND (
|
|
2697
|
+
(deadline IS NOT NULL AND deadline < now())
|
|
2698
|
+
OR (status = 'queued' AND deadline IS NULL AND created_at < now() - (${SWEEP_DEAD_QUEUED_MS} * interval '1 millisecond'))
|
|
2699
|
+
)
|
|
2700
|
+
AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
|
|
2701
|
+
LIMIT ${limit}
|
|
2702
|
+
)
|
|
2703
|
+
RETURNING id, conversation_id, error, attempt, live_seq
|
|
2704
|
+
`;
|
|
2705
|
+
result.failed = [...exhaustedRows, ...budgetRows].map((row) => ({
|
|
2706
|
+
conversationId: row.conversation_id,
|
|
2707
|
+
turnId: row.id,
|
|
2708
|
+
error: row.error,
|
|
2709
|
+
attempt: Number(row.attempt),
|
|
2710
|
+
seq: Number(row.live_seq) + 1,
|
|
2711
|
+
}));
|
|
2712
|
+
|
|
2713
|
+
// 3) Finalize aborts: cancel-requested turns without a live lease, and expired waits.
|
|
2714
|
+
const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string }[]>`
|
|
2715
|
+
UPDATE ai.turns
|
|
2716
|
+
SET status = 'aborted',
|
|
2717
|
+
completed_at = now(),
|
|
2718
|
+
cancellation_reason = COALESCE(cancellation_reason, 'sweep'),
|
|
2719
|
+
lease_owner = NULL,
|
|
2720
|
+
lease_expires_at = NULL,
|
|
2721
|
+
live_blocks = NULL
|
|
2722
|
+
WHERE id IN (
|
|
2723
|
+
SELECT id FROM ai.turns
|
|
2724
|
+
WHERE status IN ('queued', 'running', 'waiting_for_action')
|
|
2725
|
+
AND (
|
|
2726
|
+
(cancel_requested_at IS NOT NULL AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now()))
|
|
2727
|
+
OR (status = 'waiting_for_action' AND deadline IS NOT NULL AND deadline < now())
|
|
2728
|
+
)
|
|
2729
|
+
LIMIT ${limit}
|
|
2730
|
+
)
|
|
2731
|
+
RETURNING id, conversation_id, attempt, live_seq
|
|
2732
|
+
`;
|
|
2733
|
+
result.aborted = abortedRows.map((row) => ({
|
|
2734
|
+
conversationId: row.conversation_id,
|
|
2735
|
+
turnId: row.id,
|
|
2736
|
+
attempt: Number(row.attempt),
|
|
2737
|
+
seq: Number(row.live_seq) + 1,
|
|
2738
|
+
}));
|
|
2739
|
+
|
|
2740
|
+
for (const finalized of [...result.failed, ...result.aborted]) {
|
|
2741
|
+
await sql`
|
|
2742
|
+
UPDATE ai.pending_actions
|
|
2743
|
+
SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
|
|
2744
|
+
WHERE turn_id = ${finalized.turnId}
|
|
2745
|
+
AND status = 'pending'
|
|
2746
|
+
`;
|
|
2747
|
+
await sql`
|
|
2748
|
+
UPDATE ai.turn_steers
|
|
2749
|
+
SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
|
|
2750
|
+
WHERE turn_id = ${finalized.turnId}
|
|
2751
|
+
AND status = 'pending'
|
|
2752
|
+
`;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// 4) Requeue crashed running turns (lease expired, still within budget).
|
|
2756
|
+
const requeuedRows = await sql<{ id: string; conversation_id: string }[]>`
|
|
2757
|
+
UPDATE ai.turns
|
|
2758
|
+
SET status = 'queued',
|
|
2759
|
+
lease_owner = NULL,
|
|
2760
|
+
lease_expires_at = NULL
|
|
2761
|
+
WHERE id IN (
|
|
2762
|
+
SELECT id FROM ai.turns
|
|
2763
|
+
WHERE status = 'running'
|
|
2764
|
+
AND lease_expires_at IS NOT NULL
|
|
2765
|
+
AND lease_expires_at < now()
|
|
2766
|
+
AND cancel_requested_at IS NULL
|
|
2767
|
+
AND (deadline IS NULL OR deadline > now())
|
|
2768
|
+
LIMIT ${limit}
|
|
2769
|
+
)
|
|
2770
|
+
RETURNING id, conversation_id
|
|
2771
|
+
`;
|
|
2772
|
+
|
|
2773
|
+
// 5) Stale queued turns whose queue message may be lost — re-enqueue them too.
|
|
2774
|
+
const staleQueuedRows = await sql<{ id: string; conversation_id: string }[]>`
|
|
2775
|
+
SELECT id, conversation_id
|
|
2776
|
+
FROM ai.turns
|
|
2777
|
+
WHERE status = 'queued'
|
|
2778
|
+
AND cancel_requested_at IS NULL
|
|
2779
|
+
AND created_at < now() - (${SWEEP_STALE_QUEUED_MS} * interval '1 millisecond')
|
|
2780
|
+
AND (heartbeat_at IS NULL OR heartbeat_at < now() - (${SWEEP_STALE_QUEUED_MS} * interval '1 millisecond'))
|
|
2781
|
+
LIMIT ${limit}
|
|
2782
|
+
`;
|
|
2783
|
+
|
|
2784
|
+
// 6) A durable action response can outlive the queue message that was meant
|
|
2785
|
+
// to resume it. Re-enqueue exactly the action still shown as awaiting in the
|
|
2786
|
+
// turn snapshot; historical responses must not unlock a later action.
|
|
2787
|
+
const resumableWaitingRows = await sql<{ id: string; conversation_id: string }[]>`
|
|
2788
|
+
SELECT turn_row.id, turn_row.conversation_id
|
|
2789
|
+
FROM ai.turns turn_row
|
|
2790
|
+
WHERE turn_row.status = 'waiting_for_action'
|
|
2791
|
+
AND turn_row.cancel_requested_at IS NULL
|
|
2792
|
+
AND (turn_row.deadline IS NULL OR turn_row.deadline > now())
|
|
2793
|
+
AND turn_row.attempt - (
|
|
2794
|
+
SELECT COUNT(*)::int
|
|
2795
|
+
FROM ai.pending_actions action
|
|
2796
|
+
WHERE action.turn_id = turn_row.id
|
|
2797
|
+
AND action.conversation_id = turn_row.conversation_id
|
|
2798
|
+
AND action.status = 'resolved'
|
|
2799
|
+
) < ${maxAttempts}
|
|
2800
|
+
AND EXISTS (
|
|
2801
|
+
SELECT 1
|
|
2802
|
+
FROM ai.pending_actions action
|
|
2803
|
+
WHERE action.turn_id = turn_row.id
|
|
2804
|
+
AND action.conversation_id = turn_row.conversation_id
|
|
2805
|
+
AND action.status = 'resolved'
|
|
2806
|
+
AND EXISTS (
|
|
2807
|
+
SELECT 1
|
|
2808
|
+
FROM jsonb_array_elements(
|
|
2809
|
+
CASE
|
|
2810
|
+
WHEN jsonb_typeof(turn_row.live_blocks) = 'array' THEN turn_row.live_blocks
|
|
2811
|
+
ELSE '[]'::jsonb
|
|
2812
|
+
END
|
|
2813
|
+
) block
|
|
2814
|
+
WHERE block->>'kind' = 'tool'
|
|
2815
|
+
AND block->>'callId' = action.call_id
|
|
2816
|
+
AND block->>'status' IN ('awaiting_approval', 'awaiting_client')
|
|
2817
|
+
)
|
|
2818
|
+
)
|
|
2819
|
+
LIMIT ${limit}
|
|
2820
|
+
`;
|
|
2821
|
+
|
|
2822
|
+
const requeueIds = new Set<string>();
|
|
2823
|
+
for (const row of [...requeuedRows, ...staleQueuedRows, ...resumableWaitingRows]) {
|
|
2824
|
+
if (requeueIds.has(row.id)) continue;
|
|
2825
|
+
requeueIds.add(row.id);
|
|
2826
|
+
result.requeued.push({ conversationId: row.conversation_id, turnId: row.id });
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
return result;
|
|
2830
|
+
},
|
|
2831
|
+
|
|
2832
|
+
savePendingTurnAction: async (input) => {
|
|
2833
|
+
await sql`
|
|
2834
|
+
INSERT INTO ai.pending_actions (
|
|
2835
|
+
turn_id,
|
|
2836
|
+
conversation_id,
|
|
2837
|
+
call_id,
|
|
2838
|
+
kind,
|
|
2839
|
+
tool_name,
|
|
2840
|
+
args,
|
|
2841
|
+
message,
|
|
2842
|
+
review,
|
|
2843
|
+
approval_scope,
|
|
2844
|
+
allow_always,
|
|
2845
|
+
frontend_mode,
|
|
2846
|
+
status,
|
|
2847
|
+
resolved_event,
|
|
2848
|
+
resolved_at
|
|
2849
|
+
)
|
|
2850
|
+
VALUES (
|
|
2851
|
+
${input.turnId},
|
|
2852
|
+
${input.conversationId},
|
|
2853
|
+
${input.callId},
|
|
2854
|
+
${input.kind},
|
|
2855
|
+
${input.name},
|
|
2856
|
+
${JSON.stringify(input.args ?? null)}::jsonb,
|
|
2857
|
+
${input.message ?? null},
|
|
2858
|
+
${input.review ? JSON.stringify(input.review) : null}::jsonb,
|
|
2859
|
+
${input.approvalScope},
|
|
2860
|
+
${input.allowAlways},
|
|
2861
|
+
${input.frontendMode ?? null},
|
|
2862
|
+
${input.resolvedEvent ? "resolved" : "pending"},
|
|
2863
|
+
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::jsonb,
|
|
2864
|
+
CASE WHEN ${Boolean(input.resolvedEvent)} THEN now() ELSE NULL END
|
|
2865
|
+
)
|
|
2866
|
+
ON CONFLICT (turn_id, call_id)
|
|
2867
|
+
DO UPDATE SET
|
|
2868
|
+
kind = EXCLUDED.kind,
|
|
2869
|
+
tool_name = EXCLUDED.tool_name,
|
|
2870
|
+
args = EXCLUDED.args,
|
|
2871
|
+
message = EXCLUDED.message,
|
|
2872
|
+
review = EXCLUDED.review,
|
|
2873
|
+
approval_scope = EXCLUDED.approval_scope,
|
|
2874
|
+
allow_always = EXCLUDED.allow_always,
|
|
2875
|
+
frontend_mode = EXCLUDED.frontend_mode
|
|
2876
|
+
`;
|
|
2877
|
+
},
|
|
2878
|
+
|
|
2879
|
+
listPendingTurnActions: async (input) => {
|
|
2880
|
+
const rows = await sql<PendingActionRow[]>`
|
|
2881
|
+
SELECT *
|
|
2882
|
+
FROM ai.pending_actions
|
|
2883
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2884
|
+
AND turn_id = ${input.turnId}
|
|
2885
|
+
AND status = 'pending'
|
|
2886
|
+
ORDER BY created_at ASC
|
|
2887
|
+
`;
|
|
2888
|
+
return rows.map(pendingActionToPublicEvent);
|
|
2889
|
+
},
|
|
2890
|
+
|
|
2891
|
+
getPendingTurnAction: async (input) => {
|
|
2892
|
+
const rows = await sql<PendingActionRow[]>`
|
|
2893
|
+
SELECT *
|
|
2894
|
+
FROM ai.pending_actions
|
|
2895
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2896
|
+
AND turn_id = ${input.turnId}
|
|
2897
|
+
AND call_id = ${input.callId}
|
|
2898
|
+
LIMIT 1
|
|
2899
|
+
`;
|
|
2900
|
+
return rows[0] ? rowToPendingActionRecord(rows[0]) : null;
|
|
2901
|
+
},
|
|
2902
|
+
|
|
2903
|
+
listPendingActionRecords: async (input) => {
|
|
2904
|
+
const rows = await sql<PendingActionRow[]>`
|
|
2905
|
+
SELECT *
|
|
2906
|
+
FROM ai.pending_actions
|
|
2907
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2908
|
+
AND turn_id = ${input.turnId}
|
|
2909
|
+
AND status = 'pending'
|
|
2910
|
+
ORDER BY created_at ASC
|
|
2911
|
+
`;
|
|
2912
|
+
return rows.map(rowToPendingActionRecord);
|
|
2913
|
+
},
|
|
2914
|
+
|
|
2915
|
+
listResolvedPendingActions: async (input) => {
|
|
2916
|
+
const rows = await sql<PendingActionRow[]>`
|
|
2917
|
+
SELECT *
|
|
2918
|
+
FROM ai.pending_actions
|
|
2919
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2920
|
+
AND turn_id = ${input.turnId}
|
|
2921
|
+
AND status = 'resolved'
|
|
2922
|
+
ORDER BY created_at ASC
|
|
2923
|
+
`;
|
|
2924
|
+
return rows.map(rowToPendingActionRecord);
|
|
2925
|
+
},
|
|
2926
|
+
|
|
2927
|
+
resolvePendingTurnAction: async (input) => {
|
|
2928
|
+
const rows = await sql<PendingActionRow[]>`
|
|
2929
|
+
UPDATE ai.pending_actions
|
|
2930
|
+
SET status = 'resolved',
|
|
2931
|
+
resolved_event = ${JSON.stringify(input.event)}::jsonb,
|
|
2932
|
+
resolved_at = now()
|
|
2933
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2934
|
+
AND turn_id = ${input.turnId}
|
|
2935
|
+
AND call_id = ${input.callId}
|
|
2936
|
+
AND status = 'pending'
|
|
2937
|
+
RETURNING *
|
|
2938
|
+
`;
|
|
2939
|
+
return rows[0] ? rowToPendingActionRecord(rows[0]) : null;
|
|
2940
|
+
},
|
|
2941
|
+
|
|
2942
|
+
clearPendingTurnActions: async (input) => {
|
|
2943
|
+
await sql`
|
|
2944
|
+
UPDATE ai.pending_actions
|
|
2945
|
+
SET status = 'aborted',
|
|
2946
|
+
resolved_at = COALESCE(resolved_at, now())
|
|
2947
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2948
|
+
AND turn_id = ${input.turnId}
|
|
2949
|
+
AND status = 'pending'
|
|
2950
|
+
`;
|
|
2951
|
+
},
|
|
2952
|
+
|
|
2953
|
+
enqueueTurnSteer: async (input) =>
|
|
2954
|
+
sql.begin(async (tx) => {
|
|
2955
|
+
const turnRows = await tx<TurnRow[]>`
|
|
2956
|
+
SELECT *
|
|
2957
|
+
FROM ai.turns
|
|
2958
|
+
WHERE id = ${input.turnId}
|
|
2959
|
+
AND conversation_id = ${input.conversationId}
|
|
2960
|
+
FOR UPDATE
|
|
2961
|
+
`;
|
|
2962
|
+
const turn = turnRows[0];
|
|
2963
|
+
if (!turn) return { ok: false, reason: "not_found" as const };
|
|
2964
|
+
const runConfig = turn.run_config ? parseJsonValue<AiTurnRunConfig>(turn.run_config) : null;
|
|
2965
|
+
if (runConfig?.kind === "compact") return { ok: false, reason: "not_chat" as const };
|
|
2966
|
+
if (!(["queued", "running", "waiting_for_action"] as AiTurnStatus[]).includes(turn.status)) {
|
|
2967
|
+
return { ok: false, reason: "not_active" as const };
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
const existing = await tx<TurnSteerRow[]>`
|
|
2971
|
+
SELECT *
|
|
2972
|
+
FROM ai.turn_steers
|
|
2973
|
+
WHERE turn_id = ${input.turnId}
|
|
2974
|
+
AND client_request_id = ${input.clientRequestId}
|
|
2975
|
+
LIMIT 1
|
|
2976
|
+
`;
|
|
2977
|
+
if (existing[0]) return { ok: true, steer: rowToTurnSteer(existing[0]) };
|
|
2978
|
+
|
|
2979
|
+
const rows = await tx<TurnSteerRow[]>`
|
|
2980
|
+
INSERT INTO ai.turn_steers (conversation_id, turn_id, seq, client_request_id, text)
|
|
2981
|
+
VALUES (
|
|
2982
|
+
${input.conversationId},
|
|
2983
|
+
${input.turnId},
|
|
2984
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.turn_steers WHERE turn_id = ${input.turnId}),
|
|
2985
|
+
${input.clientRequestId},
|
|
2986
|
+
${input.text}
|
|
2987
|
+
)
|
|
2988
|
+
RETURNING *
|
|
2989
|
+
`;
|
|
2990
|
+
return { ok: true, steer: rowToTurnSteer(rows[0]!) };
|
|
2991
|
+
}),
|
|
2992
|
+
|
|
2993
|
+
listTurnSteers: async (input) => {
|
|
2994
|
+
const rows = await sql<TurnSteerRow[]>`
|
|
2995
|
+
SELECT *
|
|
2996
|
+
FROM ai.turn_steers
|
|
2997
|
+
WHERE conversation_id = ${input.conversationId}
|
|
2998
|
+
AND turn_id = ${input.turnId}
|
|
2999
|
+
ORDER BY seq ASC
|
|
3000
|
+
`;
|
|
3001
|
+
return rows.map(rowToTurnSteer);
|
|
3002
|
+
},
|
|
3003
|
+
|
|
3004
|
+
takePendingTurnSteers: async (input) =>
|
|
3005
|
+
sql.begin(async (tx) => {
|
|
3006
|
+
const owner = await tx<{ id: string }[]>`
|
|
3007
|
+
SELECT id
|
|
3008
|
+
FROM ai.turns
|
|
3009
|
+
WHERE id = ${input.turnId}
|
|
3010
|
+
AND conversation_id = ${input.conversationId}
|
|
3011
|
+
AND status = 'running'
|
|
3012
|
+
AND lease_owner = ${input.leaseOwner}
|
|
3013
|
+
AND cancel_requested_at IS NULL
|
|
3014
|
+
AND lease_expires_at > now()
|
|
3015
|
+
FOR UPDATE
|
|
3016
|
+
`;
|
|
3017
|
+
if (!owner[0]) throw new Error("AI turn lost its lease while taking steering.");
|
|
3018
|
+
|
|
3019
|
+
const pending = await tx<TurnSteerRow[]>`
|
|
3020
|
+
SELECT *
|
|
3021
|
+
FROM ai.turn_steers
|
|
3022
|
+
WHERE conversation_id = ${input.conversationId}
|
|
3023
|
+
AND turn_id = ${input.turnId}
|
|
3024
|
+
AND status = 'pending'
|
|
3025
|
+
ORDER BY seq ASC
|
|
3026
|
+
FOR UPDATE
|
|
3027
|
+
`;
|
|
3028
|
+
if (pending.length === 0) return [];
|
|
3029
|
+
|
|
3030
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
3031
|
+
const consumed: AiTurnSteer[] = [];
|
|
3032
|
+
for (const steer of pending) {
|
|
3033
|
+
const message = await insertMessageLocked(
|
|
3034
|
+
{
|
|
3035
|
+
conversationId: input.conversationId,
|
|
3036
|
+
message: { role: "user", content: [{ type: "text", text: steer.text }] },
|
|
3037
|
+
loopId: input.turnId,
|
|
3038
|
+
meta: { steerId: steer.id },
|
|
3039
|
+
},
|
|
3040
|
+
tx,
|
|
3041
|
+
);
|
|
3042
|
+
const rows = await tx<TurnSteerRow[]>`
|
|
3043
|
+
UPDATE ai.turn_steers
|
|
3044
|
+
SET status = 'consumed', message_id = ${message.id}, consumed_at = now()
|
|
3045
|
+
WHERE id = ${steer.id}
|
|
3046
|
+
AND status = 'pending'
|
|
3047
|
+
RETURNING *
|
|
3048
|
+
`;
|
|
3049
|
+
if (rows[0]) consumed.push(rowToTurnSteer(rows[0]));
|
|
3050
|
+
}
|
|
3051
|
+
return consumed;
|
|
3052
|
+
}),
|
|
3053
|
+
|
|
3054
|
+
createSessionStore: (input): SessionStore => ({
|
|
3055
|
+
load: async (): Promise<StoreEntry[]> => {
|
|
3056
|
+
// The loop must only ever see the active model context, never archived history.
|
|
3057
|
+
const rows = await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3058
|
+
return rows.map((row) => ({
|
|
3059
|
+
seq: row.seq,
|
|
3060
|
+
kind: row.kind,
|
|
3061
|
+
message:
|
|
3062
|
+
input.turnInput !== undefined && input.turnId === row.loopId && row.message.role === "user" && !row.meta?.steerId
|
|
3063
|
+
? {
|
|
3064
|
+
role: "user" as const,
|
|
3065
|
+
content:
|
|
3066
|
+
typeof input.turnInput === "string"
|
|
3067
|
+
? [{ type: "text" as const, text: input.turnInput }]
|
|
3068
|
+
: input.turnInput.map((part) => (typeof part === "string" ? { type: "text" as const, text: part } : part)),
|
|
3069
|
+
}
|
|
3070
|
+
: row.message,
|
|
3071
|
+
}));
|
|
3072
|
+
},
|
|
3073
|
+
append: async (message, opts) => {
|
|
3074
|
+
// Initial input and durable steering are already persisted transactionally before Nessi appends them.
|
|
3075
|
+
if (message.role === "user") return;
|
|
3076
|
+
const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
|
|
3077
|
+
|
|
3078
|
+
if (input.turnId && input.leaseOwner) {
|
|
3079
|
+
const appended = await appendTurnOwnedMessage({
|
|
3080
|
+
conversationId: input.conversationId,
|
|
3081
|
+
turnId: input.turnId,
|
|
3082
|
+
leaseOwner: input.leaseOwner,
|
|
3083
|
+
message,
|
|
3084
|
+
kind: opts?.kind,
|
|
3085
|
+
seq: opts?.seq,
|
|
3086
|
+
loopId: opts?.kind === "summary" ? null : input.turnId,
|
|
3087
|
+
modelProfileId: input.modelProfileId,
|
|
3088
|
+
meta,
|
|
3089
|
+
});
|
|
3090
|
+
if (!appended) {
|
|
3091
|
+
throw new Error("AI turn lost its lease while writing a message.");
|
|
3092
|
+
}
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
await sql.begin(async (tx) => {
|
|
3097
|
+
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
3098
|
+
await insertMessageLocked(
|
|
3099
|
+
{
|
|
3100
|
+
conversationId: input.conversationId,
|
|
3101
|
+
message,
|
|
3102
|
+
kind: opts?.kind,
|
|
3103
|
+
seq: opts?.seq,
|
|
3104
|
+
loopId: input.turnId && opts?.kind !== "summary" ? input.turnId : null,
|
|
3105
|
+
modelProfileId: input.modelProfileId,
|
|
3106
|
+
meta,
|
|
3107
|
+
},
|
|
3108
|
+
tx,
|
|
3109
|
+
);
|
|
3110
|
+
});
|
|
3111
|
+
},
|
|
3112
|
+
}),
|
|
3113
|
+
};
|