@k2b/cloud 0.6.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/package.json +128 -0
- package/public/logo.svg +1 -0
- package/scripts/app-favicon.test.ts +49 -0
- package/scripts/app-favicon.ts +51 -0
- package/scripts/build.ts +205 -0
- package/scripts/preload.ts +164 -0
- package/scripts/sync-recovery-smoke.ts +200 -0
- package/src/_internal/app-favicon.ts +2 -0
- package/src/_internal/app-presentation.ts +61 -0
- package/src/_internal/app-workspace-state.ts +24 -0
- package/src/_internal/bounded-json.ts +47 -0
- package/src/_internal/build-metadata.ts +9 -0
- package/src/_internal/capabilities.ts +1118 -0
- package/src/_internal/define-app.ts +801 -0
- package/src/_internal/heartbeat.ts +167 -0
- package/src/_internal/help-catalog.ts +241 -0
- package/src/_internal/help.ts +95 -0
- package/src/_internal/nats-connection.ts +34 -0
- package/src/_internal/page-responses.ts +40 -0
- package/src/_internal/process-sync.ts +117 -0
- package/src/_internal/readiness.ts +3 -0
- package/src/_internal/registry-validation.ts +136 -0
- package/src/_internal/registry-watch.ts +52 -0
- package/src/_internal/registry.ts +338 -0
- package/src/_internal/runtime-compatibility.ts +64 -0
- package/src/_internal/runtime-context.ts +84 -0
- package/src/_internal/runtime-watcher.ts +78 -0
- package/src/_internal/static-assets.ts +86 -0
- package/src/_internal/status-preserving-ssr.ts +49 -0
- package/src/access/PermissionEditor.tsx +486 -0
- package/src/access/ResourceApiKeys.tsx +271 -0
- package/src/access/messages.ts +121 -0
- package/src/access/ui.ts +16 -0
- package/src/account/Avatar.tsx +28 -0
- package/src/account/EntitySearch.tsx +363 -0
- package/src/account/avatar-upload-dialog.tsx +197 -0
- package/src/account/avatar-upload.ts +149 -0
- package/src/account/ui.ts +3 -0
- package/src/ai/admin.ts +47 -0
- package/src/ai/approval-routes.ts +130 -0
- package/src/ai/approvals.ts +139 -0
- package/src/ai/assistant-models.ts +61 -0
- package/src/ai/attachments.ts +40 -0
- package/src/ai/browser.ts +92 -0
- package/src/ai/capabilities.ts +714 -0
- package/src/ai/capability-execution.ts +151 -0
- package/src/ai/chat/blocks.render.test.tsx +1108 -0
- package/src/ai/chat/blocks.tsx +730 -0
- package/src/ai/chat/builtin-tools.tsx +428 -0
- package/src/ai/chat/capability-table.tsx +87 -0
- package/src/ai/chat/composer-adapter.ts +169 -0
- package/src/ai/chat/file-tools.tsx +81 -0
- package/src/ai/chat/message-actions.tsx +370 -0
- package/src/ai/chat/message-utils.ts +479 -0
- package/src/ai/chat/messages.ts +43 -0
- package/src/ai/chat/presentation.tsx +395 -0
- package/src/ai/chat/primitives.tsx +3 -0
- package/src/ai/chat/tool-disclosure.tsx +41 -0
- package/src/ai/chat/turn-navigator-utils.ts +28 -0
- package/src/ai/chat/turn-navigator.tsx +164 -0
- package/src/ai/chat/user-message.tsx +149 -0
- package/src/ai/chat/visual-tools.tsx +435 -0
- package/src/ai/chat/web-tools.tsx +202 -0
- package/src/ai/chat-task-contracts.ts +113 -0
- package/src/ai/chat-tasks.ts +952 -0
- package/src/ai/client/controller.ts +1211 -0
- package/src/ai/client/file-source.ts +95 -0
- package/src/ai/client/live-connection.ts +140 -0
- package/src/ai/client/projection.ts +178 -0
- package/src/ai/client/transport.ts +111 -0
- package/src/ai/compaction.ts +182 -0
- package/src/ai/credentials.ts +124 -0
- package/src/ai/default-tool-contracts.ts +108 -0
- package/src/ai/default-tools.ts +127 -0
- package/src/ai/enrich.ts +280 -0
- package/src/ai/executor.ts +1624 -0
- package/src/ai/fetch-file-tool.ts +313 -0
- package/src/ai/file-context.ts +92 -0
- package/src/ai/file-media-type.ts +28 -0
- package/src/ai/file-mount.ts +16 -0
- package/src/ai/file-tools.ts +566 -0
- package/src/ai/files-store.ts +390 -0
- package/src/ai/firecrawl-tools.ts +305 -0
- package/src/ai/grids-skill.ts +126 -0
- package/src/ai/http.ts +265 -0
- package/src/ai/index.ts +337 -0
- package/src/ai/kit-skill.ts +22 -0
- package/src/ai/limits.ts +5 -0
- package/src/ai/live-events.ts +289 -0
- package/src/ai/live-messages.ts +45 -0
- package/src/ai/live-outbox.ts +102 -0
- package/src/ai/live-routes.ts +412 -0
- package/src/ai/live.ts +2 -0
- package/src/ai/maintenance.ts +302 -0
- package/src/ai/markdown-pdf-tool.ts +95 -0
- package/src/ai/memories.ts +605 -0
- package/src/ai/memory-learning-runs.ts +222 -0
- package/src/ai/memory-learning.ts +619 -0
- package/src/ai/memory-tool.ts +120 -0
- package/src/ai/memory-workflow-evidence.ts +158 -0
- package/src/ai/migrate.ts +2082 -0
- package/src/ai/model-access-migrate.ts +30 -0
- package/src/ai/model-access.ts +191 -0
- package/src/ai/personal-agent.ts +6 -0
- package/src/ai/prefs.ts +72 -0
- package/src/ai/project-tool.ts +94 -0
- package/src/ai/projects-routes.ts +277 -0
- package/src/ai/projects.ts +902 -0
- package/src/ai/protocol.ts +279 -0
- package/src/ai/provider.ts +73 -0
- package/src/ai/public-projection.ts +25 -0
- package/src/ai/resource-markers.ts +31 -0
- package/src/ai/resource-refs.ts +70 -0
- package/src/ai/routes.ts +1131 -0
- package/src/ai/runtime-tools.ts +39 -0
- package/src/ai/runtime.ts +544 -0
- package/src/ai/settings.ts +529 -0
- package/src/ai/short-id.ts +45 -0
- package/src/ai/skill-catalog.ts +73 -0
- package/src/ai/skill-format.ts +145 -0
- package/src/ai/skill-seeds.ts +430 -0
- package/src/ai/skill-tool.ts +81 -0
- package/src/ai/skills-routes.ts +143 -0
- package/src/ai/skills.ts +934 -0
- package/src/ai/solid.ts +27 -0
- package/src/ai/store.ts +3113 -0
- package/src/ai/stream.ts +308 -0
- package/src/ai/structured-runs.ts +66 -0
- package/src/ai/structured.ts +186 -0
- package/src/ai/system-prompt.ts +170 -0
- package/src/ai/task-prompt.ts +22 -0
- package/src/ai/timeline.ts +148 -0
- package/src/ai/tool-audit.ts +177 -0
- package/src/ai/tool-result-budget.ts +16 -0
- package/src/ai/tools.ts +155 -0
- package/src/ai/types.ts +967 -0
- package/src/ai/ui.tsx +39 -0
- package/src/ai/usage-migrate.ts +81 -0
- package/src/ai/usage.ts +315 -0
- package/src/ai/validate.ts +54 -0
- package/src/ai/vision-tool.ts +79 -0
- package/src/api/accounts-entities.ts +174 -0
- package/src/api/admin-ai-projects.ts +74 -0
- package/src/api/admin-ai-skills.ts +76 -0
- package/src/api/admin-ai-usage.ts +42 -0
- package/src/api/admin-core-settings.ts +508 -0
- package/src/api/admin-identity.ts +148 -0
- package/src/api/admin-lifecycle.ts +206 -0
- package/src/api/admin-linux-identities.ts +66 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/app-approval.ts +129 -0
- package/src/api/apps.ts +51 -0
- package/src/api/auth/schemas.ts +54 -0
- package/src/api/auth.ts +370 -0
- package/src/api/capabilities.ts +658 -0
- package/src/api/help-messages.ts +19 -0
- package/src/api/help.ts +78 -0
- package/src/api/index.ts +108 -0
- package/src/api/mcp.ts +744 -0
- package/src/api/me-rail.ts +45 -0
- package/src/api/me.ts +753 -0
- package/src/api/search/schemas.ts +79 -0
- package/src/api/search.bench.ts +185 -0
- package/src/api/search.ts +407 -0
- package/src/api/sync-ops.ts +79 -0
- package/src/api/widgets.ts +143 -0
- package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
- package/src/browser/CloudResourceSearch.tsx +791 -0
- package/src/browser/app-approval-vault.ts +305 -0
- package/src/browser/app-approval.ts +234 -0
- package/src/browser/live-websocket.ts +268 -0
- package/src/browser/locale-preference.ts +25 -0
- package/src/browser/notification-target.ts +27 -0
- package/src/browser/notification-timeout.ts +14 -0
- package/src/browser/notifications.ts +181 -0
- package/src/browser/resource-clipboard.ts +70 -0
- package/src/browser/resource-picker.tsx +39 -0
- package/src/browser/resource-search-messages.ts +55 -0
- package/src/browser/resource-search.ts +27 -0
- package/src/capabilities/client.ts +149 -0
- package/src/capabilities/response.ts +39 -0
- package/src/capabilities/server.ts +215 -0
- package/src/capabilities/signals.ts +4 -0
- package/src/capabilities/testing.ts +12 -0
- package/src/capabilities/types.ts +32 -0
- package/src/cli/access.ts +499 -0
- package/src/cli/account.ts +382 -0
- package/src/cli/admin/account-administration.ts +45 -0
- package/src/cli/admin/account-categories.ts +49 -0
- package/src/cli/admin/ai-usage.ts +118 -0
- package/src/cli/admin/app-sign-in.ts +49 -0
- package/src/cli/admin/data.ts +343 -0
- package/src/cli/admin/documentation.ts +34 -0
- package/src/cli/admin/gateway.ts +155 -0
- package/src/cli/admin/index.ts +61 -0
- package/src/cli/admin/instance.ts +250 -0
- package/src/cli/admin/jobs.ts +223 -0
- package/src/cli/admin/legal.ts +122 -0
- package/src/cli/admin/linux.ts +53 -0
- package/src/cli/admin/logs.ts +324 -0
- package/src/cli/admin/metrics.ts +142 -0
- package/src/cli/admin/nats.ts +167 -0
- package/src/cli/admin/notifications.ts +516 -0
- package/src/cli/admin/shared.ts +113 -0
- package/src/cli/admin/sync.ts +188 -0
- package/src/cli/admin/telemetry.ts +314 -0
- package/src/cli/admin/webhooks.ts +222 -0
- package/src/cli/admin/workflows.ts +367 -0
- package/src/cli/apps.ts +40 -0
- package/src/cli/capabilities.ts +239 -0
- package/src/cli/commands.ts +604 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/locale.ts +31 -0
- package/src/cli/output.ts +58 -0
- package/src/clients/core.ts +20 -0
- package/src/config/env.ts +54 -0
- package/src/config/index.ts +6 -0
- package/src/contracts/account-categories.ts +62 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app-approval.ts +197 -0
- package/src/contracts/app.ts +162 -0
- package/src/contracts/capabilities.ts +484 -0
- package/src/contracts/index.ts +14 -0
- package/src/contracts/notification-live.ts +66 -0
- package/src/contracts/notification-types.ts +235 -0
- package/src/contracts/posix.ts +69 -0
- package/src/contracts/profile.ts +85 -0
- package/src/contracts/rail-preferences.ts +53 -0
- package/src/contracts/registry.ts +110 -0
- package/src/contracts/resource-clipboard.ts +33 -0
- package/src/contracts/settings-types.ts +98 -0
- package/src/contracts/shared.ts +408 -0
- package/src/contracts/user-notifications.ts +101 -0
- package/src/contracts/widgets.ts +183 -0
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/index.ts +58 -0
- package/src/server/actor.ts +41 -0
- package/src/server/api/index.ts +2 -0
- package/src/server/api/respond.ts +96 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/help.ts +187 -0
- package/src/server/index.ts +89 -0
- package/src/server/locale.ts +55 -0
- package/src/server/middleware/auth.ts +386 -0
- package/src/server/middleware/index.ts +28 -0
- package/src/server/middleware/invocation.ts +55 -0
- package/src/server/middleware/middleware.ts +65 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +125 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/route-template.ts +46 -0
- package/src/server/middleware/runtime.ts +22 -0
- package/src/server/middleware/settings.ts +55 -0
- package/src/server/middleware/validator.ts +49 -0
- package/src/server/middleware/workload.ts +22 -0
- package/src/server/ratelimit.ts +151 -0
- package/src/server/request-cookies.ts +18 -0
- package/src/server/services/access.ts +644 -0
- package/src/server/services/freeipa/client.ts +137 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +123 -0
- package/src/server/services/freeipa/test-certificates.ts +44 -0
- package/src/server/services/freeipa/tls.ts +44 -0
- package/src/server/services/freeipa/transport.ts +116 -0
- package/src/server/services/freeipa/util.ts +59 -0
- package/src/server/services/geo.ts +198 -0
- package/src/server/services/index.ts +48 -0
- package/src/server/services/pagination.ts +22 -0
- package/src/server/services/services.ts +13 -0
- package/src/server/time.ts +33 -0
- package/src/services/account-category-policy.ts +51 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +940 -0
- package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
- package/src/services/account-lifecycle/notification-sender.ts +13 -0
- package/src/services/account-lifecycle/scheduler.ts +506 -0
- package/src/services/account-model.ts +22 -0
- package/src/services/accounts/app.ts +1671 -0
- package/src/services/accounts/authz.ts +44 -0
- package/src/services/accounts/avatar.ts +120 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +46 -0
- package/src/services/accounts/duplicate-emails.ts +82 -0
- package/src/services/accounts/entities.ts +622 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +272 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +66 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +499 -0
- package/src/services/accounts/model.ts +129 -0
- package/src/services/accounts/notification-sender.ts +31 -0
- package/src/services/accounts/posix.ts +386 -0
- package/src/services/accounts/request-policy.ts +11 -0
- package/src/services/accounts/switching.ts +126 -0
- package/src/services/accounts/users.ts +745 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/app-approval.ts +498 -0
- package/src/services/audit/index.ts +438 -0
- package/src/services/auth-flows/index.ts +15 -0
- package/src/services/auth-flows/ipa.ts +197 -0
- package/src/services/auth-flows/magic-link.ts +185 -0
- package/src/services/auth-flows/notification-sender.ts +10 -0
- package/src/services/auth-flows/password-reset.ts +268 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/document-extraction.ts +148 -0
- package/src/services/freeipa-config.ts +179 -0
- package/src/services/gateway.ts +186 -0
- package/src/services/identity/constants.ts +18 -0
- package/src/services/identity/index.ts +52 -0
- package/src/services/identity/invocation-actor.ts +120 -0
- package/src/services/identity/invocation-authority.ts +53 -0
- package/src/services/identity/invocation-operations.ts +15 -0
- package/src/services/identity/invocation-token.ts +276 -0
- package/src/services/identity/key-config.ts +41 -0
- package/src/services/identity/key-ring.ts +530 -0
- package/src/services/identity/metrics.ts +29 -0
- package/src/services/identity/routes.ts +18 -0
- package/src/services/identity/runtime-config.ts +88 -0
- package/src/services/identity/session-token.ts +152 -0
- package/src/services/identity/signer-refresh.ts +44 -0
- package/src/services/identity/workload-auth.ts +64 -0
- package/src/services/index.ts +187 -0
- package/src/services/ipa/auth.ts +216 -0
- package/src/services/ipa/connection.ts +57 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/groups.ts +722 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/posix.ts +19 -0
- package/src/services/ipa/profile.ts +132 -0
- package/src/services/ipa/runtime.ts +28 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.ts +100 -0
- package/src/services/ipa/sync.ts +862 -0
- package/src/services/ipa/users.ts +806 -0
- package/src/services/legal-consent.ts +76 -0
- package/src/services/logging/index.ts +477 -0
- package/src/services/logging/redaction.ts +20 -0
- package/src/services/logging/trace.ts +1231 -0
- package/src/services/mandates/index.ts +855 -0
- package/src/services/mandates/policy.ts +85 -0
- package/src/services/network-security.ts +31 -0
- package/src/services/notifications/batches.ts +722 -0
- package/src/services/notifications/browser.ts +252 -0
- package/src/services/notifications/catalog.ts +113 -0
- package/src/services/notifications/channels.ts +104 -0
- package/src/services/notifications/dispatcher.ts +139 -0
- package/src/services/notifications/email.ts +118 -0
- package/src/services/notifications/index.ts +633 -0
- package/src/services/notifications/live.ts +50 -0
- package/src/services/notifications/observability.ts +407 -0
- package/src/services/notifications/platform.ts +421 -0
- package/src/services/notifications/runtime.ts +120 -0
- package/src/services/notifications/user.ts +248 -0
- package/src/services/notifications/web-push-transport.ts +135 -0
- package/src/services/oauth-tokens.ts +209 -0
- package/src/services/outbox.ts +187 -0
- package/src/services/pdf/gotenberg.ts +290 -0
- package/src/services/pdf/index.ts +43 -0
- package/src/services/pdf/markdown.ts +209 -0
- package/src/services/pdf/template-preview.ts +122 -0
- package/src/services/postgres.ts +76 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +51 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +263 -0
- package/src/services/rail-preferences.ts +27 -0
- package/src/services/runtime-lifecycle.ts +191 -0
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.ts +891 -0
- package/src/services/service-accounts.ts +184 -0
- package/src/services/session/index.ts +236 -0
- package/src/services/session/test-fixture.ts +18 -0
- package/src/services/session/user.ts +144 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +86 -0
- package/src/services/settings/core-settings.ts +796 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +374 -0
- package/src/services/settings/index.ts +215 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +241 -0
- package/src/services/settings/templates.ts +4 -0
- package/src/services/sync-ops.ts +403 -0
- package/src/services/topic-cursor.ts +22 -0
- package/src/services/weather/forecast.ts +323 -0
- package/src/services/weather/geo.ts +119 -0
- package/src/services/weather/index.ts +67 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +133 -0
- package/src/services/weather/migrate.ts +45 -0
- package/src/services/weather/settings.ts +66 -0
- package/src/services/weather/short-id.ts +57 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/services/webauthn.ts +385 -0
- package/src/shared/account-action-notice.ts +70 -0
- package/src/shared/account-display.ts +15 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/ai-platform-prompt.ts +134 -0
- package/src/shared/ai-usage.ts +49 -0
- package/src/shared/app-presentation.ts +48 -0
- package/src/shared/app-url.ts +12 -0
- package/src/shared/branding.ts +14 -0
- package/src/shared/capability-messages.ts +70 -0
- package/src/shared/email-html.ts +54 -0
- package/src/shared/format.ts +91 -0
- package/src/shared/help.ts +68 -0
- package/src/shared/icons.ts +400 -0
- package/src/shared/index.ts +54 -0
- package/src/shared/locale.ts +48 -0
- package/src/shared/login-method.ts +44 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +105 -0
- package/src/shared/markdown/extensions/guided-help.ts +100 -0
- package/src/shared/markdown/extensions/images.ts +79 -0
- package/src/shared/markdown/extensions/info-blocks.ts +97 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +43 -0
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +109 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +193 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/network-address.ts +106 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/template-rendering.ts +140 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +53 -0
- package/src/ssr/AdminSidebar.tsx +50 -0
- package/src/ssr/AppLaunchpad.island.tsx +236 -0
- package/src/ssr/AppWorkspaceController.island.tsx +21 -0
- package/src/ssr/BrowserNotifications.island.tsx +135 -0
- package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
- package/src/ssr/GlobalSearchDialog.tsx +35 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
- package/src/ssr/Layout.render.test.tsx +148 -0
- package/src/ssr/Layout.tsx +292 -0
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutFooter.tsx +22 -0
- package/src/ssr/LayoutHeader.tsx +81 -0
- package/src/ssr/LayoutHelp.tsx +869 -0
- package/src/ssr/LayoutPreferences.island.tsx +36 -0
- package/src/ssr/LayoutRail.tsx +58 -0
- package/src/ssr/MinimalLayout.tsx +46 -0
- package/src/ssr/PageError.tsx +29 -0
- package/src/ssr/ProfilePreferences.island.tsx +92 -0
- package/src/ssr/RailApps.island.tsx +61 -0
- package/src/ssr/RailEditor.tsx +293 -0
- package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/admin-active-link.ts +47 -0
- package/src/ssr/admin-navigation.ts +78 -0
- package/src/ssr/app-appearance.ts +23 -0
- package/src/ssr/app-navigation.ts +41 -0
- package/src/ssr/help-messages.ts +104 -0
- package/src/ssr/index.ts +14 -0
- package/src/ssr/islands/SearchBar.island.tsx +80 -0
- package/src/ssr/islands/index.ts +16 -0
- package/src/ssr/layout-context.ts +17 -0
- package/src/ssr/layout-help-markdown.ts +9 -0
- package/src/ssr/layout-help-navigation.ts +24 -0
- package/src/ssr/layout-help-url.ts +5 -0
- package/src/ssr/layout-runtime.ts +22 -0
- package/src/ssr/page-error-messages.ts +25 -0
- package/src/ssr/platform-messages.ts +135 -0
- package/src/ssr/preference-controller.ts +44 -0
- package/src/ssr/profile-preferences-messages.ts +29 -0
- package/src/ssr/rail-context.ts +21 -0
- package/src/ssr/rail-messages.ts +66 -0
- package/src/ssr/rail-navigation.ts +60 -0
- package/src/ssr/runtime.ts +31 -0
- package/src/ssr/url-filter.ts +127 -0
- package/src/styles/base-popover.css +36 -0
- package/src/styles/effects.css +851 -0
- package/src/styles/global.css +268 -0
- package/src/styles/input.css +58 -0
- package/src/styles/tokens.css +230 -0
- package/src/styles/utilities-buttons.css +405 -0
- package/src/styles/utilities-data.css +33 -0
- package/src/styles/utilities-feedback.css +232 -0
- package/src/styles/utilities-layout.css +603 -0
- package/src/styles/utilities-markdown-editor.css +525 -0
- package/src/styles/utilities-markdown-table.css +234 -0
- package/src/styles/utilities-navigation.css +1054 -0
- package/src/types/ambient.d.ts +24 -0
- package/src/workflows/ai/index.ts +17 -0
- package/src/workflows/ai/runtime.ts +286 -0
- package/src/workflows/ai/store.ts +314 -0
- package/src/workflows/ai/types.ts +111 -0
- package/src/workflows/ai-actions.ts +269 -0
- package/src/workflows/authoring.ts +88 -0
- package/src/workflows/builtins.ts +193 -0
- package/src/workflows/contracts.ts +280 -0
- package/src/workflows/definition.ts +334 -0
- package/src/workflows/editor.ts +113 -0
- package/src/workflows/index.ts +71 -0
- package/src/workflows/language/binder.ts +45 -0
- package/src/workflows/language/canonical.ts +29 -0
- package/src/workflows/language/compiler.ts +396 -0
- package/src/workflows/language/expressions.ts +34 -0
- package/src/workflows/language/index.ts +20 -0
- package/src/workflows/language/references.ts +55 -0
- package/src/workflows/language/schema.ts +134 -0
- package/src/workflows/language/strict-yaml.ts +131 -0
- package/src/workflows/manifest.ts +24 -0
- package/src/workflows/module.ts +12 -0
- package/src/workflows/runtime/coordinator.ts +115 -0
- package/src/workflows/runtime/executor.ts +1003 -0
- package/src/workflows/runtime/index.ts +60 -0
- package/src/workflows/runtime/instant.ts +8 -0
- package/src/workflows/runtime/ports.ts +213 -0
- package/src/workflows/runtime/schedule.ts +203 -0
- package/src/workflows/runtime/trigger.ts +37 -0
- package/src/workflows/runtime/wake.ts +13 -0
- package/src/workflows/store/actions.ts +438 -0
- package/src/workflows/store/budget.ts +145 -0
- package/src/workflows/store/definitions.ts +345 -0
- package/src/workflows/store/events.ts +383 -0
- package/src/workflows/store/index.ts +107 -0
- package/src/workflows/store/observability.ts +594 -0
- package/src/workflows/store/runs.ts +975 -0
- package/src/workflows/store/transaction.ts +23 -0
- package/src/workflows/store/worker.ts +439 -0
- package/src/workflows/testing/conformance.ts +72 -0
- package/src/workflows/testing/index.ts +11 -0
- package/src/workflows/testing/process-fixtures.ts +275 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import type { Message } from "@k2b/nessi";
|
|
2
|
+
import { type SQL, sql } from "bun";
|
|
3
|
+
import { logger } from "../services/logging";
|
|
4
|
+
import {
|
|
5
|
+
createMandate,
|
|
6
|
+
type Mandate,
|
|
7
|
+
type MandatePolicyV1,
|
|
8
|
+
MandatePolicyV1Schema,
|
|
9
|
+
pauseMandate,
|
|
10
|
+
resumeMandate,
|
|
11
|
+
revokeMandate,
|
|
12
|
+
} from "../services/mandates";
|
|
13
|
+
import { parsePgJsonValue } from "../services/postgres";
|
|
14
|
+
import { withAiShortIdForDb } from "./short-id";
|
|
15
|
+
import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
|
|
16
|
+
|
|
17
|
+
export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
|
|
18
|
+
export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
|
|
19
|
+
export type AiChatTask = {
|
|
20
|
+
id: string;
|
|
21
|
+
shortId: string;
|
|
22
|
+
chatId: string;
|
|
23
|
+
chatTitle: string;
|
|
24
|
+
conversationId: string;
|
|
25
|
+
sponsorUserId: string;
|
|
26
|
+
mandateId: string | null;
|
|
27
|
+
mandateRevision: number | null;
|
|
28
|
+
prompt: string;
|
|
29
|
+
schedule: AiChatTaskSchedule;
|
|
30
|
+
timezone: string;
|
|
31
|
+
state: AiChatTaskState;
|
|
32
|
+
revision: number;
|
|
33
|
+
lastError: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type AiChatTaskOccurrenceState = "queued" | "running" | "completed" | "failed";
|
|
39
|
+
export type AiChatTaskOccurrence = {
|
|
40
|
+
id: string;
|
|
41
|
+
shortId: string;
|
|
42
|
+
taskId: string;
|
|
43
|
+
scheduledFor: string;
|
|
44
|
+
trigger: "scheduled" | "manual";
|
|
45
|
+
state: AiChatTaskOccurrenceState;
|
|
46
|
+
turnId: string | null;
|
|
47
|
+
error: string | null;
|
|
48
|
+
createdAt: string;
|
|
49
|
+
startedAt: string | null;
|
|
50
|
+
completedAt: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type TaskRow = {
|
|
54
|
+
id: string;
|
|
55
|
+
short_id: string;
|
|
56
|
+
conversation_id: string;
|
|
57
|
+
conversation_short_id: string;
|
|
58
|
+
conversation_title: string;
|
|
59
|
+
sponsor_user_id: string;
|
|
60
|
+
mandate_id: string | null;
|
|
61
|
+
mandate_revision: number | bigint | null;
|
|
62
|
+
prompt: string;
|
|
63
|
+
schedule_kind: "once" | "cron";
|
|
64
|
+
run_at: Date | string | null;
|
|
65
|
+
cron: string | null;
|
|
66
|
+
timezone: string;
|
|
67
|
+
state: AiChatTaskState;
|
|
68
|
+
revision: number | bigint;
|
|
69
|
+
last_error: string | null;
|
|
70
|
+
created_at: Date | string;
|
|
71
|
+
updated_at: Date | string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export class AiChatTaskIdempotencyConflictError extends Error {
|
|
75
|
+
constructor() {
|
|
76
|
+
super("The idempotency key was already used with different task input");
|
|
77
|
+
this.name = "AiChatTaskIdempotencyConflictError";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
class AiChatTaskCreateRace extends Error {}
|
|
82
|
+
class AiChatTaskAuthorityError extends Error {}
|
|
83
|
+
const log = logger("ai:chat-tasks");
|
|
84
|
+
|
|
85
|
+
type OccurrenceRow = {
|
|
86
|
+
id: string;
|
|
87
|
+
short_id: string;
|
|
88
|
+
task_id: string;
|
|
89
|
+
scheduled_for: Date | string;
|
|
90
|
+
trigger: "scheduled" | "manual";
|
|
91
|
+
state: AiChatTaskOccurrenceState;
|
|
92
|
+
turn_id: string | null;
|
|
93
|
+
error: string | null;
|
|
94
|
+
created_at: Date | string;
|
|
95
|
+
started_at: Date | string | null;
|
|
96
|
+
completed_at: Date | string | null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const iso = (value: Date | string): string => new Date(value).toISOString();
|
|
100
|
+
const nullableIso = (value: Date | string | null): string | null => (value === null ? null : iso(value));
|
|
101
|
+
const taskIdempotencyKey = (key: string): string => `task.create:${key}`;
|
|
102
|
+
const CHAT_TASK_MANDATE_POLICY = {
|
|
103
|
+
version: 1,
|
|
104
|
+
apps: "*",
|
|
105
|
+
operations: "*",
|
|
106
|
+
actions: "require_approval",
|
|
107
|
+
} as const satisfies MandatePolicyV1;
|
|
108
|
+
const mandateAuthority = (userId: string) => ({ kind: "interactive" as const, userId });
|
|
109
|
+
const mandateWorkloadAuthority = { kind: "workload" as const, ownerAppId: "core" };
|
|
110
|
+
const mandateError = (operation: string, result: { ok: false; error: { message: string } }): Error =>
|
|
111
|
+
new AiChatTaskAuthorityError(`Could not ${operation} scheduled task mandate: ${result.error.message}`);
|
|
112
|
+
const isChatTaskMandatePolicy = (policy: MandatePolicyV1): boolean =>
|
|
113
|
+
policy.version === CHAT_TASK_MANDATE_POLICY.version &&
|
|
114
|
+
policy.apps === CHAT_TASK_MANDATE_POLICY.apps &&
|
|
115
|
+
policy.operations === CHAT_TASK_MANDATE_POLICY.operations &&
|
|
116
|
+
policy.actions === CHAT_TASK_MANDATE_POLICY.actions;
|
|
117
|
+
const mandateRevision = async (
|
|
118
|
+
task: { mandate_id: string; mandate_revision: number | bigint },
|
|
119
|
+
state: "active" | "paused",
|
|
120
|
+
userId: string,
|
|
121
|
+
db: SQL,
|
|
122
|
+
): Promise<number> => {
|
|
123
|
+
const input = {
|
|
124
|
+
mandateId: task.mandate_id,
|
|
125
|
+
expectedRevision: Number(task.mandate_revision),
|
|
126
|
+
authority: mandateAuthority(userId),
|
|
127
|
+
};
|
|
128
|
+
const result = state === "active" ? await resumeMandate(input, { db }) : await pauseMandate(input, { db });
|
|
129
|
+
if (!result.ok) throw mandateError(state === "active" ? "resume" : "pause", result);
|
|
130
|
+
return result.data.revision;
|
|
131
|
+
};
|
|
132
|
+
const requireTaskMandate = (
|
|
133
|
+
task: Pick<TaskRow, "mandate_id" | "mandate_revision">,
|
|
134
|
+
): { mandate_id: string; mandate_revision: number | bigint } => {
|
|
135
|
+
if (!task.mandate_id || task.mandate_revision === null)
|
|
136
|
+
throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable; recreate the task");
|
|
137
|
+
return { mandate_id: task.mandate_id, mandate_revision: task.mandate_revision };
|
|
138
|
+
};
|
|
139
|
+
type TaskMandateRow = {
|
|
140
|
+
id: string;
|
|
141
|
+
revision: number | bigint;
|
|
142
|
+
state: Mandate["state"];
|
|
143
|
+
subject_kind: string;
|
|
144
|
+
subject_user_id: string | null;
|
|
145
|
+
owner_app_id: string;
|
|
146
|
+
workload_type: string;
|
|
147
|
+
workload_id: string;
|
|
148
|
+
confirmed_at: Date | string | null;
|
|
149
|
+
unexpired: boolean;
|
|
150
|
+
policy: unknown;
|
|
151
|
+
};
|
|
152
|
+
const loadTaskMandate = async (task: TaskRow, db: SQL): Promise<TaskMandateRow | null> => {
|
|
153
|
+
if (!task.mandate_id) return null;
|
|
154
|
+
const [mandate] = await db<TaskMandateRow[]>`
|
|
155
|
+
SELECT id, revision, state, subject_kind, subject_user_id, owner_app_id, workload_type, workload_id,
|
|
156
|
+
confirmed_at, (expires_at IS NULL OR expires_at > now()) AS unexpired, policy
|
|
157
|
+
FROM auth.mandates WHERE id = ${task.mandate_id}::uuid
|
|
158
|
+
FOR UPDATE
|
|
159
|
+
`;
|
|
160
|
+
return mandate ?? null;
|
|
161
|
+
};
|
|
162
|
+
const taskMandateMatches = (task: TaskRow, mandate: TaskMandateRow): boolean =>
|
|
163
|
+
mandate.subject_kind === "user" &&
|
|
164
|
+
mandate.subject_user_id === task.sponsor_user_id &&
|
|
165
|
+
mandate.owner_app_id === "core" &&
|
|
166
|
+
mandate.workload_type === "ai.chat-task" &&
|
|
167
|
+
mandate.workload_id === task.id;
|
|
168
|
+
const taskMandatePolicy = (mandate: TaskMandateRow): MandatePolicyV1 | null => {
|
|
169
|
+
try {
|
|
170
|
+
const policy = MandatePolicyV1Schema.safeParse(parsePgJsonValue(mandate.policy));
|
|
171
|
+
return policy.success ? policy.data : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const stopTaskAdmission = async (
|
|
177
|
+
task: TaskRow,
|
|
178
|
+
state: "paused" | "needs_attention",
|
|
179
|
+
reason: string | null,
|
|
180
|
+
db: SQL,
|
|
181
|
+
pausedMandateRevision?: number,
|
|
182
|
+
): Promise<null> => {
|
|
183
|
+
await db`
|
|
184
|
+
UPDATE ai.chat_tasks
|
|
185
|
+
SET state = ${state}, last_error = ${reason},
|
|
186
|
+
mandate_revision = COALESCE(${pausedMandateRevision ?? null}::bigint, mandate_revision),
|
|
187
|
+
revision = revision + 1, updated_at = now()
|
|
188
|
+
WHERE id = ${task.id}::uuid AND state = 'active'
|
|
189
|
+
`;
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
/** The caller holds the task lock; mandate and sponsor locks remain held until turn admission commits. */
|
|
193
|
+
const prepareTaskForExecution = async (input: TaskRow, db: SQL): Promise<TaskRow | null> => {
|
|
194
|
+
if (input.state !== "active") return null;
|
|
195
|
+
const task = input;
|
|
196
|
+
const mandate = await loadTaskMandate(task, db);
|
|
197
|
+
const policy = mandate ? taskMandatePolicy(mandate) : null;
|
|
198
|
+
const [sponsor] = await db<{ id: string }[]>`
|
|
199
|
+
SELECT sponsor.id FROM auth.users sponsor
|
|
200
|
+
JOIN ai.conversations conversation ON conversation.created_by_user_id = sponsor.id
|
|
201
|
+
WHERE sponsor.id = ${task.sponsor_user_id}::uuid AND conversation.id = ${task.conversation_id}::uuid
|
|
202
|
+
AND (sponsor.account_expires IS NULL OR sponsor.account_expires > now()) AND conversation.archived_at IS NULL
|
|
203
|
+
FOR SHARE OF sponsor
|
|
204
|
+
FOR UPDATE OF conversation
|
|
205
|
+
`;
|
|
206
|
+
if (
|
|
207
|
+
!sponsor ||
|
|
208
|
+
!mandate ||
|
|
209
|
+
!taskMandateMatches(task, mandate) ||
|
|
210
|
+
!mandate.confirmed_at ||
|
|
211
|
+
!mandate.unexpired ||
|
|
212
|
+
!policy ||
|
|
213
|
+
!isChatTaskMandatePolicy(policy) ||
|
|
214
|
+
mandate.state === "revoked"
|
|
215
|
+
) {
|
|
216
|
+
return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate or sponsor is unavailable", db);
|
|
217
|
+
}
|
|
218
|
+
if (mandate.state === "paused") return stopTaskAdmission(task, "paused", null, db, Number(mandate.revision));
|
|
219
|
+
if (task.mandate_revision === null || Number(task.mandate_revision) !== Number(mandate.revision)) {
|
|
220
|
+
return stopTaskAdmission(task, "needs_attention", "Scheduled task mandate revision changed", db);
|
|
221
|
+
}
|
|
222
|
+
return task;
|
|
223
|
+
};
|
|
224
|
+
/** Terminal accounting may narrow existing authority, but must never create or resume it. */
|
|
225
|
+
const pauseTerminalTaskMandate = async (task: TaskRow, db: SQL): Promise<number | null> => {
|
|
226
|
+
const mandate = await loadTaskMandate(task, db);
|
|
227
|
+
if (!mandate || !taskMandateMatches(task, mandate)) return task.mandate_revision === null ? null : Number(task.mandate_revision);
|
|
228
|
+
if (mandate.state !== "active" || !taskMandatePolicy(mandate)) return Number(mandate.revision);
|
|
229
|
+
const paused = await pauseMandate(
|
|
230
|
+
{ mandateId: mandate.id, expectedRevision: Number(mandate.revision), authority: mandateWorkloadAuthority },
|
|
231
|
+
{ db },
|
|
232
|
+
);
|
|
233
|
+
if (!paused.ok) throw mandateError("pause", paused);
|
|
234
|
+
return paused.data.revision;
|
|
235
|
+
};
|
|
236
|
+
const taskFingerprint = (input: { chatId: string; prompt: string; schedule: AiChatTaskSchedule; timezone: string }): string =>
|
|
237
|
+
new Bun.CryptoHasher("sha256").update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone])).digest("hex");
|
|
238
|
+
const taskSelect = sql`
|
|
239
|
+
SELECT task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
|
|
240
|
+
FROM ai.chat_tasks task
|
|
241
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
const toTask = (row: TaskRow): AiChatTask => ({
|
|
245
|
+
id: row.id,
|
|
246
|
+
shortId: row.short_id,
|
|
247
|
+
chatId: row.conversation_short_id,
|
|
248
|
+
chatTitle: row.conversation_title,
|
|
249
|
+
conversationId: row.conversation_id,
|
|
250
|
+
sponsorUserId: row.sponsor_user_id,
|
|
251
|
+
mandateId: row.mandate_id,
|
|
252
|
+
mandateRevision: row.mandate_revision === null ? null : Number(row.mandate_revision),
|
|
253
|
+
prompt: row.prompt,
|
|
254
|
+
schedule: row.schedule_kind === "once" ? { kind: "once", runAt: iso(row.run_at!) } : { kind: "cron", cron: row.cron! },
|
|
255
|
+
timezone: row.timezone,
|
|
256
|
+
state: row.state,
|
|
257
|
+
revision: Number(row.revision),
|
|
258
|
+
lastError: row.last_error,
|
|
259
|
+
createdAt: iso(row.created_at),
|
|
260
|
+
updatedAt: iso(row.updated_at),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const toOccurrence = (row: OccurrenceRow): AiChatTaskOccurrence => ({
|
|
264
|
+
id: row.id,
|
|
265
|
+
shortId: row.short_id,
|
|
266
|
+
taskId: row.task_id,
|
|
267
|
+
scheduledFor: iso(row.scheduled_for),
|
|
268
|
+
trigger: row.trigger,
|
|
269
|
+
state: row.state,
|
|
270
|
+
turnId: row.turn_id,
|
|
271
|
+
error: row.error,
|
|
272
|
+
createdAt: iso(row.created_at),
|
|
273
|
+
startedAt: nullableIso(row.started_at),
|
|
274
|
+
completedAt: nullableIso(row.completed_at),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const prepareTaskById = (taskId: string): Promise<AiChatTask | null> =>
|
|
278
|
+
sql.begin(async (tx) => {
|
|
279
|
+
const [task] = await tx<TaskRow[]>`
|
|
280
|
+
${taskSelect}
|
|
281
|
+
WHERE task.id = ${taskId}::uuid
|
|
282
|
+
FOR UPDATE OF task
|
|
283
|
+
`;
|
|
284
|
+
const prepared = task ? await prepareTaskForExecution(task, tx) : null;
|
|
285
|
+
return prepared ? toTask(prepared) : null;
|
|
286
|
+
});
|
|
287
|
+
const loadOccurrenceTask = async (occurrenceId: string, db: SQL): Promise<TaskRow | null> => {
|
|
288
|
+
const [task] = await db<TaskRow[]>`
|
|
289
|
+
${taskSelect}
|
|
290
|
+
WHERE EXISTS (
|
|
291
|
+
SELECT 1 FROM ai.chat_task_occurrences occurrence
|
|
292
|
+
WHERE occurrence.id = ${occurrenceId}::uuid AND occurrence.task_id = task.id
|
|
293
|
+
)
|
|
294
|
+
FOR UPDATE OF task
|
|
295
|
+
`;
|
|
296
|
+
return task ?? null;
|
|
297
|
+
};
|
|
298
|
+
const loadTurnTask = async (turnId: string, db: SQL): Promise<TaskRow | null> => {
|
|
299
|
+
const [task] = await db<TaskRow[]>`
|
|
300
|
+
${taskSelect}
|
|
301
|
+
WHERE EXISTS (
|
|
302
|
+
SELECT 1 FROM ai.chat_task_occurrences occurrence
|
|
303
|
+
WHERE occurrence.turn_id = ${turnId}::uuid AND occurrence.task_id = task.id
|
|
304
|
+
)
|
|
305
|
+
FOR UPDATE OF task
|
|
306
|
+
`;
|
|
307
|
+
return task ?? null;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const aiChatTasks = {
|
|
311
|
+
list: async (input: {
|
|
312
|
+
userId: string;
|
|
313
|
+
chatId?: string;
|
|
314
|
+
state?: AiChatTaskState;
|
|
315
|
+
limit?: number;
|
|
316
|
+
offset?: number;
|
|
317
|
+
}): Promise<AiChatTask[]> => {
|
|
318
|
+
const rows = await sql<TaskRow[]>`
|
|
319
|
+
${taskSelect}
|
|
320
|
+
WHERE task.sponsor_user_id = ${input.userId}::uuid
|
|
321
|
+
AND (${input.chatId ?? null}::text IS NULL OR conversation.short_id = ${input.chatId ?? null})
|
|
322
|
+
AND (${input.state ?? null}::text IS NULL OR task.state = ${input.state ?? null})
|
|
323
|
+
ORDER BY task.created_at DESC, task.id DESC
|
|
324
|
+
LIMIT ${Math.min(Math.max(input.limit ?? 50, 1), 100)}
|
|
325
|
+
OFFSET ${Math.max(input.offset ?? 0, 0)}
|
|
326
|
+
`;
|
|
327
|
+
return rows.map(toTask);
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
get: async (input: { userId: string; taskId: string }): Promise<AiChatTask | null> => {
|
|
331
|
+
const rows = await sql<TaskRow[]>`
|
|
332
|
+
${taskSelect}
|
|
333
|
+
WHERE task.sponsor_user_id = ${input.userId}::uuid
|
|
334
|
+
AND task.short_id = ${input.taskId}
|
|
335
|
+
LIMIT 1
|
|
336
|
+
`;
|
|
337
|
+
return rows[0] ? toTask(rows[0]) : null;
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
create: async (input: {
|
|
341
|
+
userId: string;
|
|
342
|
+
chatId: string;
|
|
343
|
+
prompt: string;
|
|
344
|
+
schedule: AiChatTaskSchedule;
|
|
345
|
+
timezone: string;
|
|
346
|
+
idempotencyKey?: string;
|
|
347
|
+
idempotencyFingerprint?: string;
|
|
348
|
+
}): Promise<AiChatTask | null> => {
|
|
349
|
+
const scopedKey = input.idempotencyKey ? taskIdempotencyKey(input.idempotencyKey) : null;
|
|
350
|
+
const fingerprint = scopedKey ? (input.idempotencyFingerprint ?? taskFingerprint(input)) : null;
|
|
351
|
+
if (input.idempotencyKey) {
|
|
352
|
+
const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
|
|
353
|
+
${taskSelect}
|
|
354
|
+
WHERE task.idempotency_key = ${scopedKey}
|
|
355
|
+
AND task.sponsor_user_id = ${input.userId}::uuid
|
|
356
|
+
LIMIT 1
|
|
357
|
+
`;
|
|
358
|
+
if (existing[0]) {
|
|
359
|
+
if (existing[0].idempotency_fingerprint !== fingerprint) throw new AiChatTaskIdempotencyConflictError();
|
|
360
|
+
return toTask(existing[0]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
let rows: TaskRow[];
|
|
364
|
+
try {
|
|
365
|
+
rows = await sql.begin(async (tx) => {
|
|
366
|
+
const conversations = await tx<{ id: string; title: string }[]>`
|
|
367
|
+
SELECT id, title FROM ai.conversations
|
|
368
|
+
WHERE short_id = ${input.chatId}
|
|
369
|
+
AND created_by_user_id = ${input.userId}::uuid
|
|
370
|
+
AND archived_at IS NULL
|
|
371
|
+
LIMIT 1
|
|
372
|
+
`;
|
|
373
|
+
const conversation = conversations[0];
|
|
374
|
+
if (!conversation) return [];
|
|
375
|
+
const taskId = crypto.randomUUID();
|
|
376
|
+
const mandate = await createMandate(
|
|
377
|
+
{
|
|
378
|
+
authority: mandateAuthority(input.userId),
|
|
379
|
+
subject: { type: "user", id: input.userId },
|
|
380
|
+
ownerAppId: "core",
|
|
381
|
+
workloadType: "ai.chat-task",
|
|
382
|
+
workloadId: taskId,
|
|
383
|
+
policy: CHAT_TASK_MANDATE_POLICY,
|
|
384
|
+
},
|
|
385
|
+
{ db: tx },
|
|
386
|
+
);
|
|
387
|
+
if (!mandate.ok) throw mandateError("create", mandate);
|
|
388
|
+
const inserted = await withAiShortIdForDb(
|
|
389
|
+
tx,
|
|
390
|
+
"ai_chat_tasks_short_id_unique",
|
|
391
|
+
(attempt, shortId) => attempt<TaskRow[]>`
|
|
392
|
+
INSERT INTO ai.chat_tasks (
|
|
393
|
+
id, short_id, conversation_id, sponsor_user_id, prompt, schedule_kind, run_at, cron, timezone,
|
|
394
|
+
mandate_id, mandate_revision, idempotency_key, idempotency_fingerprint
|
|
395
|
+
)
|
|
396
|
+
VALUES (
|
|
397
|
+
${taskId}::uuid, ${shortId}, ${conversation.id}::uuid, ${input.userId}::uuid, ${input.prompt.trim()}, ${input.schedule.kind},
|
|
398
|
+
${input.schedule.kind === "once" ? input.schedule.runAt : null}::timestamptz,
|
|
399
|
+
${input.schedule.kind === "cron" ? input.schedule.cron : null}, ${input.timezone},
|
|
400
|
+
${mandate.data.id}::uuid, ${mandate.data.revision}, ${scopedKey}, ${fingerprint}
|
|
401
|
+
)
|
|
402
|
+
ON CONFLICT (sponsor_user_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
|
|
403
|
+
RETURNING *, ${input.chatId} AS conversation_short_id, ${conversation.title} AS conversation_title
|
|
404
|
+
`,
|
|
405
|
+
);
|
|
406
|
+
const task = inserted[0];
|
|
407
|
+
if (!task) throw new AiChatTaskCreateRace();
|
|
408
|
+
return inserted;
|
|
409
|
+
});
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (!(error instanceof AiChatTaskCreateRace)) throw error;
|
|
412
|
+
rows = [];
|
|
413
|
+
}
|
|
414
|
+
if (rows[0]) return toTask(rows[0]);
|
|
415
|
+
if (!input.idempotencyKey) return null;
|
|
416
|
+
const existing = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
|
|
417
|
+
${taskSelect}
|
|
418
|
+
WHERE task.idempotency_key = ${scopedKey}
|
|
419
|
+
AND task.sponsor_user_id = ${input.userId}::uuid
|
|
420
|
+
LIMIT 1
|
|
421
|
+
`;
|
|
422
|
+
if (!existing[0]) return null;
|
|
423
|
+
if (existing[0].idempotency_fingerprint !== fingerprint) throw new AiChatTaskIdempotencyConflictError();
|
|
424
|
+
return toTask(existing[0]);
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
getCreateByIdempotency: async (input: {
|
|
428
|
+
userId: string;
|
|
429
|
+
idempotencyKey: string;
|
|
430
|
+
idempotencyFingerprint: string;
|
|
431
|
+
}): Promise<AiChatTask | null> => {
|
|
432
|
+
const rows = await sql<(TaskRow & { idempotency_fingerprint: string | null })[]>`
|
|
433
|
+
${taskSelect}
|
|
434
|
+
WHERE task.idempotency_key = ${taskIdempotencyKey(input.idempotencyKey)}
|
|
435
|
+
AND task.sponsor_user_id = ${input.userId}::uuid
|
|
436
|
+
LIMIT 1
|
|
437
|
+
`;
|
|
438
|
+
if (!rows[0]) return null;
|
|
439
|
+
if (rows[0].idempotency_fingerprint !== input.idempotencyFingerprint) throw new AiChatTaskIdempotencyConflictError();
|
|
440
|
+
return toTask(rows[0]);
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
update: async (input: {
|
|
444
|
+
userId: string;
|
|
445
|
+
taskId: string;
|
|
446
|
+
prompt?: string;
|
|
447
|
+
schedule?: AiChatTaskSchedule;
|
|
448
|
+
timezone?: string;
|
|
449
|
+
}): Promise<AiChatTask | null> =>
|
|
450
|
+
sql.begin(async (tx) => {
|
|
451
|
+
const rows = await tx<TaskRow[]>`
|
|
452
|
+
${taskSelect}
|
|
453
|
+
WHERE task.sponsor_user_id = ${input.userId}::uuid
|
|
454
|
+
AND task.short_id = ${input.taskId}
|
|
455
|
+
FOR UPDATE OF task
|
|
456
|
+
`;
|
|
457
|
+
let current = rows[0];
|
|
458
|
+
if (!current) return null;
|
|
459
|
+
requireTaskMandate(current);
|
|
460
|
+
const wasActive = current.state === "active";
|
|
461
|
+
const authorityStopped = wasActive && !(await prepareTaskForExecution(current, tx));
|
|
462
|
+
if (authorityStopped) {
|
|
463
|
+
[current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
|
|
464
|
+
if (!current) return null;
|
|
465
|
+
}
|
|
466
|
+
const nextTimezone = input.timezone ?? current.timezone;
|
|
467
|
+
const scheduleChanged = input.schedule
|
|
468
|
+
? input.schedule.kind !== current.schedule_kind ||
|
|
469
|
+
(input.schedule.kind === "once" ? iso(input.schedule.runAt) !== iso(current.run_at!) : input.schedule.cron !== current.cron) ||
|
|
470
|
+
nextTimezone !== current.timezone
|
|
471
|
+
: false;
|
|
472
|
+
const promptChanged = input.prompt !== undefined && input.prompt.trim() !== current.prompt;
|
|
473
|
+
if (!promptChanged && !scheduleChanged) return toTask(current);
|
|
474
|
+
await tx`
|
|
475
|
+
UPDATE ai.chat_tasks task
|
|
476
|
+
SET prompt = ${input.prompt?.trim() ?? current.prompt},
|
|
477
|
+
schedule_kind = ${input.schedule?.kind ?? current.schedule_kind},
|
|
478
|
+
run_at = ${input.schedule ? (input.schedule.kind === "once" ? input.schedule.runAt : null) : current.run_at}::timestamptz,
|
|
479
|
+
cron = ${input.schedule ? (input.schedule.kind === "cron" ? input.schedule.cron : null) : current.cron},
|
|
480
|
+
timezone = ${input.timezone ?? current.timezone},
|
|
481
|
+
state = CASE
|
|
482
|
+
WHEN ${authorityStopped} THEN task.state
|
|
483
|
+
WHEN task.state = 'paused' THEN 'paused'
|
|
484
|
+
WHEN task.state = 'completed' AND ${scheduleChanged} = false THEN 'completed'
|
|
485
|
+
ELSE 'active'
|
|
486
|
+
END,
|
|
487
|
+
last_error = CASE
|
|
488
|
+
WHEN ${authorityStopped} OR task.state = 'paused' OR (task.state = 'completed' AND ${scheduleChanged} = false)
|
|
489
|
+
THEN task.last_error
|
|
490
|
+
ELSE NULL
|
|
491
|
+
END,
|
|
492
|
+
revision = task.revision + 1, updated_at = now()
|
|
493
|
+
WHERE id = ${current.id}::uuid
|
|
494
|
+
`;
|
|
495
|
+
if (scheduleChanged) {
|
|
496
|
+
await tx`
|
|
497
|
+
UPDATE ai.chat_task_occurrences
|
|
498
|
+
SET state = 'failed', error = 'Task schedule changed', completed_at = now()
|
|
499
|
+
WHERE task_id = ${current.id}::uuid AND state = 'queued'
|
|
500
|
+
`;
|
|
501
|
+
}
|
|
502
|
+
const updated = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${current.id}::uuid`;
|
|
503
|
+
const next = updated[0]!;
|
|
504
|
+
if (next.state === "active" && !wasActive) {
|
|
505
|
+
const revision = await mandateRevision(requireTaskMandate(next), "active", input.userId, tx);
|
|
506
|
+
if (revision !== Number(next.mandate_revision)) {
|
|
507
|
+
next.mandate_revision = revision;
|
|
508
|
+
await tx`UPDATE ai.chat_tasks SET mandate_revision = ${revision} WHERE id = ${next.id}::uuid`;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return toTask(next);
|
|
512
|
+
}),
|
|
513
|
+
|
|
514
|
+
setState: async (input: { userId: string; taskId: string; state: "active" | "paused" }): Promise<AiChatTask | null> => {
|
|
515
|
+
return sql.begin(async (tx) => {
|
|
516
|
+
const rows = await tx<TaskRow[]>`
|
|
517
|
+
${taskSelect}
|
|
518
|
+
WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
|
|
519
|
+
FOR UPDATE OF task
|
|
520
|
+
`;
|
|
521
|
+
let current = rows[0];
|
|
522
|
+
if (!current) return null;
|
|
523
|
+
requireTaskMandate(current);
|
|
524
|
+
const allowed =
|
|
525
|
+
current.state === input.state ||
|
|
526
|
+
(current.state === "active" && input.state === "paused") ||
|
|
527
|
+
(current.state === "paused" && input.state === "active") ||
|
|
528
|
+
(current.state === "needs_attention" && current.schedule_kind === "cron" && input.state === "active");
|
|
529
|
+
if (!allowed) return null;
|
|
530
|
+
const mandate = await loadTaskMandate(current, tx);
|
|
531
|
+
if (!mandate || !taskMandateMatches(current, mandate)) {
|
|
532
|
+
throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
|
|
533
|
+
}
|
|
534
|
+
if (input.state === "active") {
|
|
535
|
+
const policy = taskMandatePolicy(mandate);
|
|
536
|
+
if (!mandate.confirmed_at || !mandate.unexpired || !policy || !isChatTaskMandatePolicy(policy)) {
|
|
537
|
+
throw new AiChatTaskAuthorityError("Scheduled task mandate cannot be resumed");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const revision = await mandateRevision({ mandate_id: mandate.id, mandate_revision: mandate.revision }, input.state, input.userId, tx);
|
|
541
|
+
const updated = await tx<TaskRow[]>`
|
|
542
|
+
UPDATE ai.chat_tasks task
|
|
543
|
+
SET state = ${input.state}, mandate_revision = ${revision},
|
|
544
|
+
last_error = CASE WHEN task.state = ${input.state} THEN task.last_error ELSE NULL END,
|
|
545
|
+
revision = CASE WHEN task.state = ${input.state} THEN task.revision ELSE task.revision + 1 END,
|
|
546
|
+
updated_at = CASE WHEN task.state = ${input.state} THEN task.updated_at ELSE now() END
|
|
547
|
+
FROM ai.conversations conversation
|
|
548
|
+
WHERE conversation.id = task.conversation_id AND task.id = ${current.id}::uuid
|
|
549
|
+
RETURNING task.*, conversation.short_id AS conversation_short_id, conversation.title AS conversation_title
|
|
550
|
+
`;
|
|
551
|
+
return updated[0] ? toTask(updated[0]) : null;
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
delete: async (input: { userId: string; taskId: string }): Promise<boolean> => {
|
|
556
|
+
return sql.begin(async (tx) => {
|
|
557
|
+
const rows = await tx<TaskRow[]>`
|
|
558
|
+
${taskSelect}
|
|
559
|
+
WHERE task.sponsor_user_id = ${input.userId}::uuid AND task.short_id = ${input.taskId}
|
|
560
|
+
FOR UPDATE OF task
|
|
561
|
+
`;
|
|
562
|
+
let current = rows[0];
|
|
563
|
+
if (!current) return false;
|
|
564
|
+
if (!current.mandate_id && current.mandate_revision === null) {
|
|
565
|
+
await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
requireTaskMandate(current);
|
|
569
|
+
const mandate = await loadTaskMandate(current, tx);
|
|
570
|
+
if (!mandate || !taskMandateMatches(current, mandate)) {
|
|
571
|
+
throw new AiChatTaskAuthorityError("Scheduled task mandate is unavailable");
|
|
572
|
+
}
|
|
573
|
+
const revoked = await revokeMandate(
|
|
574
|
+
{
|
|
575
|
+
mandateId: mandate.id,
|
|
576
|
+
expectedRevision: Number(mandate.revision),
|
|
577
|
+
authority: mandateAuthority(input.userId),
|
|
578
|
+
reason: "Scheduled chat task deleted",
|
|
579
|
+
},
|
|
580
|
+
{ db: tx },
|
|
581
|
+
);
|
|
582
|
+
if (!revoked.ok) throw mandateError("revoke", revoked);
|
|
583
|
+
await tx`DELETE FROM ai.chat_tasks WHERE id = ${current.id}::uuid`;
|
|
584
|
+
return true;
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
listOccurrences: async (input: { userId: string; taskId: string; limit?: number }): Promise<AiChatTaskOccurrence[] | null> => {
|
|
589
|
+
const task = await aiChatTasks.get(input);
|
|
590
|
+
if (!task) return null;
|
|
591
|
+
const rows = await sql<OccurrenceRow[]>`
|
|
592
|
+
SELECT * FROM ai.chat_task_occurrences
|
|
593
|
+
WHERE task_id = ${task.id}::uuid
|
|
594
|
+
ORDER BY created_at DESC, id DESC
|
|
595
|
+
LIMIT ${Math.min(Math.max(input.limit ?? 20, 1), 100)}
|
|
596
|
+
`;
|
|
597
|
+
return rows.map(toOccurrence);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
listActiveCron: async (): Promise<AiChatTask[]> => {
|
|
601
|
+
const rows = await sql<TaskRow[]>`
|
|
602
|
+
${taskSelect}
|
|
603
|
+
WHERE task.state = 'active' AND task.schedule_kind = 'cron'
|
|
604
|
+
`;
|
|
605
|
+
const tasks: AiChatTask[] = [];
|
|
606
|
+
for (const row of rows) {
|
|
607
|
+
try {
|
|
608
|
+
const prepared = await prepareTaskById(row.id);
|
|
609
|
+
if (prepared) tasks.push(prepared);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
log.warn("Scheduled task reconciliation failed", { taskId: row.id, error: error instanceof Error ? error.message : String(error) });
|
|
612
|
+
// Retain the registration during transient storage failures. Every run still revalidates authority.
|
|
613
|
+
tasks.push(toTask(row));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return tasks;
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
createOccurrence: async (input: {
|
|
620
|
+
taskId: string;
|
|
621
|
+
scheduledFor: string;
|
|
622
|
+
trigger: "scheduled" | "manual";
|
|
623
|
+
requestKey: string;
|
|
624
|
+
expectedRevision?: number;
|
|
625
|
+
}): Promise<AiChatTaskOccurrence | null> => {
|
|
626
|
+
return sql.begin(async (tx) => {
|
|
627
|
+
const [current] = await tx<TaskRow[]>`${taskSelect} WHERE task.id = ${input.taskId}::uuid FOR UPDATE OF task`;
|
|
628
|
+
if (!current || !(await prepareTaskForExecution(current, tx))) return null;
|
|
629
|
+
const rows = await withAiShortIdForDb(
|
|
630
|
+
tx,
|
|
631
|
+
"ai_chat_task_occurrences_short_id_unique",
|
|
632
|
+
(attempt, shortId) => attempt<OccurrenceRow[]>`
|
|
633
|
+
INSERT INTO ai.chat_task_occurrences (short_id, task_id, scheduled_for, trigger, request_key, task_revision)
|
|
634
|
+
SELECT ${shortId}, task.id, ${input.scheduledFor}::timestamptz, ${input.trigger}, ${input.requestKey}, task.revision
|
|
635
|
+
FROM ai.chat_tasks task
|
|
636
|
+
WHERE task.id = ${input.taskId}::uuid AND task.state = 'active'
|
|
637
|
+
AND (${input.expectedRevision ?? null}::bigint IS NULL OR task.revision = ${input.expectedRevision ?? null}::bigint)
|
|
638
|
+
ON CONFLICT DO NOTHING
|
|
639
|
+
RETURNING *
|
|
640
|
+
`,
|
|
641
|
+
);
|
|
642
|
+
if (rows[0]) return toOccurrence(rows[0]);
|
|
643
|
+
const existing = await tx<OccurrenceRow[]>`
|
|
644
|
+
SELECT * FROM ai.chat_task_occurrences WHERE request_key = ${input.requestKey} LIMIT 1
|
|
645
|
+
`;
|
|
646
|
+
if (!existing[0]) return null;
|
|
647
|
+
if (existing[0].task_id !== input.taskId) throw new AiChatTaskIdempotencyConflictError();
|
|
648
|
+
return toOccurrence(existing[0]);
|
|
649
|
+
});
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
materializeDueOnce: async (limit = 100): Promise<AiChatTaskOccurrence[]> => {
|
|
653
|
+
const tasks = await sql<{ id: string; run_at: Date | string; revision: number | bigint }[]>`
|
|
654
|
+
SELECT task.id, task.run_at, task.revision
|
|
655
|
+
FROM ai.chat_tasks task
|
|
656
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
657
|
+
WHERE task.state = 'active' AND task.schedule_kind = 'once' AND task.run_at <= now()
|
|
658
|
+
ORDER BY run_at, id
|
|
659
|
+
LIMIT ${Math.min(Math.max(limit, 1), 500)}
|
|
660
|
+
`;
|
|
661
|
+
const occurrences: AiChatTaskOccurrence[] = [];
|
|
662
|
+
for (const task of tasks) {
|
|
663
|
+
try {
|
|
664
|
+
const slot = iso(task.run_at);
|
|
665
|
+
const occurrence = await aiChatTasks.createOccurrence({
|
|
666
|
+
taskId: task.id,
|
|
667
|
+
scheduledFor: slot,
|
|
668
|
+
trigger: "scheduled",
|
|
669
|
+
requestKey: `once:${task.id}:${slot}`,
|
|
670
|
+
expectedRevision: Number(task.revision),
|
|
671
|
+
});
|
|
672
|
+
if (occurrence?.state === "queued") occurrences.push(occurrence);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
log.warn("Scheduled task materialization failed", {
|
|
675
|
+
taskId: task.id,
|
|
676
|
+
error: error instanceof Error ? error.message : String(error),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return occurrences;
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
listQueuedOccurrences: async (limit = 100): Promise<Array<{ occurrence: AiChatTaskOccurrence; task: AiChatTask }>> => {
|
|
684
|
+
const rows = await sql<(OccurrenceRow & TaskRow)[]>`
|
|
685
|
+
SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
|
|
686
|
+
occurrence.state, occurrence.turn_id, occurrence.error, occurrence.created_at, occurrence.started_at, occurrence.completed_at,
|
|
687
|
+
task.id AS task_row_id, task.short_id AS task_short_id, task.conversation_id, conversation.short_id AS conversation_short_id,
|
|
688
|
+
conversation.title AS conversation_title, task.sponsor_user_id, task.mandate_id, task.mandate_revision,
|
|
689
|
+
task.prompt, task.schedule_kind, task.run_at, task.cron,
|
|
690
|
+
task.timezone, task.state AS task_state, task.revision, task.last_error, task.created_at AS task_created_at, task.updated_at
|
|
691
|
+
FROM (
|
|
692
|
+
SELECT candidate.*,
|
|
693
|
+
row_number() OVER (PARTITION BY task.conversation_id ORDER BY candidate.created_at, candidate.id) AS conversation_rank
|
|
694
|
+
FROM ai.chat_task_occurrences candidate
|
|
695
|
+
JOIN ai.chat_tasks task ON task.id = candidate.task_id
|
|
696
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
697
|
+
WHERE candidate.state = 'queued' AND task.state = 'active'
|
|
698
|
+
AND task.mandate_id IS NOT NULL AND task.mandate_revision IS NOT NULL
|
|
699
|
+
AND NOT EXISTS (
|
|
700
|
+
SELECT 1 FROM ai.turns turn
|
|
701
|
+
WHERE turn.conversation_id = task.conversation_id
|
|
702
|
+
AND turn.status IN ('queued', 'running', 'waiting_for_action')
|
|
703
|
+
)
|
|
704
|
+
) occurrence
|
|
705
|
+
JOIN ai.chat_tasks task ON task.id = occurrence.task_id
|
|
706
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
707
|
+
WHERE occurrence.conversation_rank = 1
|
|
708
|
+
ORDER BY occurrence.created_at, occurrence.id
|
|
709
|
+
LIMIT ${Math.min(Math.max(limit, 1), 500)}
|
|
710
|
+
`;
|
|
711
|
+
return rows.map((row) => ({
|
|
712
|
+
occurrence: toOccurrence(row),
|
|
713
|
+
task: toTask({
|
|
714
|
+
...row,
|
|
715
|
+
id: (row as typeof row & { task_row_id: string }).task_row_id,
|
|
716
|
+
short_id: (row as typeof row & { task_short_id: string }).task_short_id,
|
|
717
|
+
state: (row as typeof row & { task_state: AiChatTaskState }).task_state,
|
|
718
|
+
created_at: (row as typeof row & { task_created_at: Date | string }).task_created_at,
|
|
719
|
+
}),
|
|
720
|
+
}));
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
getQueuedOccurrence: async (occurrenceId: string): Promise<{ occurrence: AiChatTaskOccurrence; task: AiChatTask } | null> => {
|
|
724
|
+
return sql.begin(async (tx) => {
|
|
725
|
+
const occurrences = await tx<OccurrenceRow[]>`
|
|
726
|
+
SELECT * FROM ai.chat_task_occurrences WHERE id = ${occurrenceId}::uuid AND state = 'queued' LIMIT 1
|
|
727
|
+
`;
|
|
728
|
+
const occurrence = occurrences[0];
|
|
729
|
+
if (!occurrence) return null;
|
|
730
|
+
const tasks = await tx<TaskRow[]>`
|
|
731
|
+
${taskSelect}
|
|
732
|
+
WHERE task.id = ${occurrence.task_id}::uuid AND task.state = 'active'
|
|
733
|
+
LIMIT 1
|
|
734
|
+
FOR UPDATE OF task
|
|
735
|
+
`;
|
|
736
|
+
if (!tasks[0]) return null;
|
|
737
|
+
const task = await prepareTaskForExecution(tasks[0], tx);
|
|
738
|
+
if (!task) return null;
|
|
739
|
+
await tx`
|
|
740
|
+
UPDATE ai.chat_task_occurrences SET task_revision = ${task.revision}::bigint
|
|
741
|
+
WHERE id = ${occurrence.id}::uuid AND state = 'queued'
|
|
742
|
+
`;
|
|
743
|
+
return { occurrence: toOccurrence(occurrence), task: toTask(task) };
|
|
744
|
+
});
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
failOccurrence: async (input: { occurrenceId: string; error: string }): Promise<"failed" | "stale" | "gone"> =>
|
|
748
|
+
sql.begin(async (tx) => {
|
|
749
|
+
const task = await loadOccurrenceTask(input.occurrenceId, tx);
|
|
750
|
+
if (!task) return "gone" as const;
|
|
751
|
+
const rows = await tx<{ task_id: string; task_revision: number | bigint }[]>`
|
|
752
|
+
UPDATE ai.chat_task_occurrences occurrence
|
|
753
|
+
SET state = 'failed', error = ${input.error}, completed_at = now()
|
|
754
|
+
FROM ai.chat_tasks task
|
|
755
|
+
WHERE occurrence.task_id = task.id
|
|
756
|
+
AND occurrence.id = ${input.occurrenceId}::uuid
|
|
757
|
+
AND occurrence.state IN ('queued', 'running')
|
|
758
|
+
AND occurrence.task_revision = task.revision
|
|
759
|
+
RETURNING occurrence.task_id, occurrence.task_revision
|
|
760
|
+
`;
|
|
761
|
+
if (rows[0]) {
|
|
762
|
+
const revision = await pauseTerminalTaskMandate(task, tx);
|
|
763
|
+
await tx`
|
|
764
|
+
UPDATE ai.chat_tasks
|
|
765
|
+
SET state = 'needs_attention', last_error = ${input.error}, mandate_revision = ${revision},
|
|
766
|
+
revision = revision + 1, updated_at = now()
|
|
767
|
+
WHERE id = ${rows[0].task_id}::uuid AND revision = ${rows[0].task_revision}::bigint
|
|
768
|
+
`;
|
|
769
|
+
return "failed" as const;
|
|
770
|
+
}
|
|
771
|
+
const stale = await tx<{ stale: boolean }[]>`
|
|
772
|
+
SELECT true AS stale
|
|
773
|
+
FROM ai.chat_task_occurrences occurrence
|
|
774
|
+
JOIN ai.chat_tasks task ON task.id = occurrence.task_id
|
|
775
|
+
WHERE occurrence.id = ${input.occurrenceId}::uuid
|
|
776
|
+
AND occurrence.state IN ('queued', 'running')
|
|
777
|
+
AND occurrence.task_revision <> task.revision
|
|
778
|
+
LIMIT 1
|
|
779
|
+
`;
|
|
780
|
+
return stale[0] ? ("stale" as const) : ("gone" as const);
|
|
781
|
+
}),
|
|
782
|
+
|
|
783
|
+
deliverOccurrence: async (input: {
|
|
784
|
+
occurrenceId: string;
|
|
785
|
+
modelProfileId: string;
|
|
786
|
+
runConfig: AiChatTurnRunConfig;
|
|
787
|
+
userMessage: Message;
|
|
788
|
+
expectedRevision: number;
|
|
789
|
+
}): Promise<{ delivered: true; conversationId: string; turnId: string } | { delivered: false; reason: "not_found" | "busy" | "stale" }> =>
|
|
790
|
+
sql.begin(async (tx) => {
|
|
791
|
+
const current = await loadOccurrenceTask(input.occurrenceId, tx);
|
|
792
|
+
const task = current ? await prepareTaskForExecution(current, tx) : null;
|
|
793
|
+
if (!task) {
|
|
794
|
+
return { delivered: false as const, reason: "not_found" as const };
|
|
795
|
+
}
|
|
796
|
+
const rows = await tx<
|
|
797
|
+
{
|
|
798
|
+
id: string;
|
|
799
|
+
short_id: string;
|
|
800
|
+
task_id: string;
|
|
801
|
+
task_short_id: string;
|
|
802
|
+
scheduled_for: Date | string;
|
|
803
|
+
trigger: "scheduled" | "manual";
|
|
804
|
+
state: AiChatTaskOccurrenceState;
|
|
805
|
+
conversation_id: string;
|
|
806
|
+
archived_at: Date | null;
|
|
807
|
+
task_revision: number | bigint;
|
|
808
|
+
}[]
|
|
809
|
+
>`
|
|
810
|
+
SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
|
|
811
|
+
occurrence.state, task.short_id AS task_short_id, task.conversation_id, conversation.archived_at,
|
|
812
|
+
task.revision AS task_revision
|
|
813
|
+
FROM ai.chat_task_occurrences occurrence
|
|
814
|
+
JOIN ai.chat_tasks task ON task.id = occurrence.task_id
|
|
815
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
816
|
+
WHERE occurrence.id = ${input.occurrenceId}::uuid AND task.state = 'active'
|
|
817
|
+
FOR UPDATE OF occurrence, task, conversation
|
|
818
|
+
`;
|
|
819
|
+
const occurrence = rows[0];
|
|
820
|
+
if (!occurrence || occurrence.state !== "queued") return { delivered: false as const, reason: "not_found" as const };
|
|
821
|
+
if (occurrence.archived_at) return { delivered: false as const, reason: "not_found" as const };
|
|
822
|
+
if (Number(occurrence.task_revision) !== input.expectedRevision) return { delivered: false as const, reason: "stale" as const };
|
|
823
|
+
const active = await tx<{ id: string }[]>`
|
|
824
|
+
SELECT id FROM ai.turns
|
|
825
|
+
WHERE conversation_id = ${occurrence.conversation_id}::uuid
|
|
826
|
+
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
827
|
+
LIMIT 1
|
|
828
|
+
`;
|
|
829
|
+
if (active[0]) return { delivered: false as const, reason: "busy" as const };
|
|
830
|
+
const mandate = requireTaskMandate(task);
|
|
831
|
+
const runConfig: AiChatTurnRunConfig = {
|
|
832
|
+
...input.runConfig,
|
|
833
|
+
mandate: { id: mandate.mandate_id, revision: Number(mandate.mandate_revision) },
|
|
834
|
+
};
|
|
835
|
+
const turn = await withAiShortIdForDb(
|
|
836
|
+
tx,
|
|
837
|
+
"idx_ai_turns_conversation_short_id",
|
|
838
|
+
(attempt, shortId) => attempt<{ id: string }[]>`
|
|
839
|
+
INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
|
|
840
|
+
VALUES (${shortId}, ${occurrence.conversation_id}::uuid, ${input.modelProfileId}, 'queued', (${JSON.stringify(runConfig)}::text)::jsonb)
|
|
841
|
+
RETURNING id
|
|
842
|
+
`,
|
|
843
|
+
);
|
|
844
|
+
const turnId = turn[0]!.id;
|
|
845
|
+
const messageMeta: NonNullable<AiStoredMessage["meta"]> = {
|
|
846
|
+
scheduledTask: {
|
|
847
|
+
taskId: occurrence.task_short_id,
|
|
848
|
+
occurrenceId: occurrence.short_id,
|
|
849
|
+
scheduledFor: iso(occurrence.scheduled_for),
|
|
850
|
+
trigger: occurrence.trigger,
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
await withAiShortIdForDb(
|
|
854
|
+
tx,
|
|
855
|
+
"idx_ai_messages_conversation_short_id",
|
|
856
|
+
(attempt, shortId) => attempt`
|
|
857
|
+
INSERT INTO ai.messages (short_id, conversation_id, seq, kind, role, message, search_text, loop_id, meta)
|
|
858
|
+
VALUES (
|
|
859
|
+
${shortId}, ${occurrence.conversation_id}::uuid,
|
|
860
|
+
(SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${occurrence.conversation_id}::uuid AND seq > 0),
|
|
861
|
+
'message', 'user', (${JSON.stringify(input.userMessage)}::text)::jsonb, ${input.runConfig.input}, ${turnId}::uuid,
|
|
862
|
+
(${JSON.stringify(messageMeta)}::text)::jsonb
|
|
863
|
+
)
|
|
864
|
+
`,
|
|
865
|
+
);
|
|
866
|
+
await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${occurrence.conversation_id}::uuid`;
|
|
867
|
+
await tx`
|
|
868
|
+
UPDATE ai.chat_task_occurrences
|
|
869
|
+
SET state = 'running', turn_id = ${turnId}::uuid, task_revision = ${occurrence.task_revision}::bigint, started_at = now()
|
|
870
|
+
WHERE id = ${occurrence.id}::uuid
|
|
871
|
+
`;
|
|
872
|
+
return { delivered: true as const, conversationId: occurrence.conversation_id, turnId };
|
|
873
|
+
}),
|
|
874
|
+
|
|
875
|
+
finalizeTurn: async (input: {
|
|
876
|
+
turnId: string;
|
|
877
|
+
status: AiTurnStatus;
|
|
878
|
+
error?: string | null;
|
|
879
|
+
}): Promise<{ occurrenceId: string; failed: boolean } | null> =>
|
|
880
|
+
sql.begin(async (tx) => {
|
|
881
|
+
const task = await loadTurnTask(input.turnId, tx);
|
|
882
|
+
if (!task) return null;
|
|
883
|
+
const rows = await tx<
|
|
884
|
+
{
|
|
885
|
+
occurrence_id: string;
|
|
886
|
+
task_id: string;
|
|
887
|
+
schedule_kind: "once" | "cron";
|
|
888
|
+
trigger: "scheduled" | "manual";
|
|
889
|
+
scheduled_for: Date | string;
|
|
890
|
+
run_at: Date | string | null;
|
|
891
|
+
task_revision: number | bigint;
|
|
892
|
+
current_revision: number | bigint;
|
|
893
|
+
turn_error: string | null;
|
|
894
|
+
}[]
|
|
895
|
+
>`
|
|
896
|
+
UPDATE ai.chat_task_occurrences occurrence
|
|
897
|
+
SET state = ${input.status === "completed" ? "completed" : "failed"},
|
|
898
|
+
error = CASE
|
|
899
|
+
WHEN ${input.status} = 'completed' THEN NULL
|
|
900
|
+
ELSE COALESCE(${input.error ?? null}, turn.error, ${`Scheduled turn ${input.status}`})
|
|
901
|
+
END,
|
|
902
|
+
completed_at = now()
|
|
903
|
+
FROM ai.chat_tasks task, ai.turns turn
|
|
904
|
+
WHERE occurrence.task_id = task.id AND occurrence.turn_id = ${input.turnId}::uuid AND turn.id = occurrence.turn_id
|
|
905
|
+
AND occurrence.state = 'running'
|
|
906
|
+
RETURNING occurrence.id AS occurrence_id, occurrence.task_id, task.schedule_kind, occurrence.trigger,
|
|
907
|
+
occurrence.scheduled_for, task.run_at, occurrence.task_revision, task.revision AS current_revision, turn.error AS turn_error
|
|
908
|
+
`;
|
|
909
|
+
const row = rows[0];
|
|
910
|
+
if (!row) return null;
|
|
911
|
+
const unchangedSinceStart = Number(row.task_revision) === Number(row.current_revision);
|
|
912
|
+
const currentOnceSlot = row.run_at !== null && iso(row.run_at) === iso(row.scheduled_for);
|
|
913
|
+
if (
|
|
914
|
+
unchangedSinceStart &&
|
|
915
|
+
input.status === "completed" &&
|
|
916
|
+
row.schedule_kind === "once" &&
|
|
917
|
+
row.trigger === "scheduled" &&
|
|
918
|
+
currentOnceSlot
|
|
919
|
+
) {
|
|
920
|
+
const revision = await pauseTerminalTaskMandate(task, tx);
|
|
921
|
+
await tx`
|
|
922
|
+
UPDATE ai.chat_tasks
|
|
923
|
+
SET state = 'completed', last_error = NULL, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
|
|
924
|
+
WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
|
|
925
|
+
`;
|
|
926
|
+
} else if (unchangedSinceStart && input.status !== "completed") {
|
|
927
|
+
const error = input.error || row.turn_error || `Scheduled turn ${input.status}`;
|
|
928
|
+
const revision = await pauseTerminalTaskMandate(task, tx);
|
|
929
|
+
await tx`
|
|
930
|
+
UPDATE ai.chat_tasks
|
|
931
|
+
SET state = 'needs_attention', last_error = ${error}, mandate_revision = ${revision}, revision = revision + 1, updated_at = now()
|
|
932
|
+
WHERE id = ${row.task_id}::uuid AND revision = ${row.task_revision}::bigint
|
|
933
|
+
`;
|
|
934
|
+
}
|
|
935
|
+
return { occurrenceId: row.occurrence_id, failed: input.status !== "completed" };
|
|
936
|
+
}),
|
|
937
|
+
|
|
938
|
+
listTerminalRunningTurns: async (limit = 100): Promise<Array<{ turnId: string; status: "completed" | "failed" | "aborted" }>> => {
|
|
939
|
+
const rows = await sql<{ turn_id: string; status: "completed" | "failed" | "aborted" }[]>`
|
|
940
|
+
SELECT occurrence.turn_id, turn.status
|
|
941
|
+
FROM ai.chat_task_occurrences occurrence
|
|
942
|
+
JOIN ai.chat_tasks task ON task.id = occurrence.task_id
|
|
943
|
+
JOIN ai.conversations conversation ON conversation.id = task.conversation_id
|
|
944
|
+
JOIN ai.turns turn ON turn.id = occurrence.turn_id
|
|
945
|
+
WHERE occurrence.state = 'running'
|
|
946
|
+
AND turn.status IN ('completed', 'failed', 'aborted')
|
|
947
|
+
ORDER BY turn.completed_at, occurrence.id
|
|
948
|
+
LIMIT ${Math.min(Math.max(limit, 1), 500)}
|
|
949
|
+
`;
|
|
950
|
+
return rows.map((row) => ({ turnId: row.turn_id, status: row.status }));
|
|
951
|
+
},
|
|
952
|
+
} as const;
|