@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,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kernel's run store.
|
|
3
|
+
*
|
|
4
|
+
* Grids and Mail each wrote their own version of this — 904 and 717 lines that
|
|
5
|
+
* agreed on almost everything and differed in the places that matter: Mail
|
|
6
|
+
* leased a target while Grids leased a run, Mail fenced with a generation *and*
|
|
7
|
+
* a token while Grids used the generation alone, and Mail's `heartbeat`
|
|
8
|
+
* silently ignored the run it was passed.
|
|
9
|
+
*
|
|
10
|
+
* Execution is "find the first step with no recorded outcome, run it, record
|
|
11
|
+
* it". Crash recovery is that same loop, which is why there is no separate
|
|
12
|
+
* recovery path below: a claim on a run whose lease expired resumes it, and
|
|
13
|
+
* the journal makes the steps that already happened free to skip.
|
|
14
|
+
*
|
|
15
|
+
* The fence is `execution_generation`. Every claim increments it, so a worker
|
|
16
|
+
* that lost its lease writes with a stale generation and every one of its
|
|
17
|
+
* updates matches zero rows. Nothing here trusts wall-clock ordering.
|
|
18
|
+
*/
|
|
19
|
+
import { type SQL, sql } from "bun";
|
|
20
|
+
import type { WorkflowBoundPlan, WorkflowDependency, WorkflowInvocationMode, WorkflowJsonValue, WorkflowRunState } from "../contracts";
|
|
21
|
+
import type {
|
|
22
|
+
WorkflowCoordinatorFinishState,
|
|
23
|
+
WorkflowCoordinatorLeaseState,
|
|
24
|
+
WorkflowCoordinatorPort,
|
|
25
|
+
WorkflowCoordinatorReleaseState,
|
|
26
|
+
} from "../runtime/coordinator";
|
|
27
|
+
import { WorkflowLeaseLostError } from "../runtime/executor";
|
|
28
|
+
import type {
|
|
29
|
+
WorkflowHeartbeatOutcome,
|
|
30
|
+
WorkflowRestoredStep,
|
|
31
|
+
WorkflowRuntimeRepositoryPort,
|
|
32
|
+
WorkflowRuntimeRunIdentity,
|
|
33
|
+
WorkflowRuntimeStepIdentity,
|
|
34
|
+
WorkflowRuntimeStepResult,
|
|
35
|
+
} from "../runtime/ports";
|
|
36
|
+
import { withTransaction } from "./transaction";
|
|
37
|
+
|
|
38
|
+
/** How long a claim survives without a heartbeat. */
|
|
39
|
+
export const WORKFLOW_RUN_LEASE_MS = 120_000;
|
|
40
|
+
|
|
41
|
+
/** How long a released run waits before it can be picked up again. */
|
|
42
|
+
const RELEASE_BACKOFF_MS = 5_000;
|
|
43
|
+
const RELEASE_MAX_BACKOFF_MS = 5 * 60_000;
|
|
44
|
+
|
|
45
|
+
/** A repeatedly crashing/retryable run eventually becomes an operator-visible failure. */
|
|
46
|
+
export const WORKFLOW_RUN_MAX_CONSECUTIVE_FAILURES = 8;
|
|
47
|
+
|
|
48
|
+
/** A run state that no longer moves on its own. */
|
|
49
|
+
const TERMINAL_RUN_STATES: readonly WorkflowRunState[] = ["succeeded", "failed", "canceled", "needs_attention"];
|
|
50
|
+
|
|
51
|
+
export type WorkflowRunClaim = {
|
|
52
|
+
runId: string;
|
|
53
|
+
executionGeneration: number;
|
|
54
|
+
mode: WorkflowInvocationMode;
|
|
55
|
+
workflowId: string;
|
|
56
|
+
workflowVersionId: string;
|
|
57
|
+
sourceHash: string;
|
|
58
|
+
idempotencyKey: string;
|
|
59
|
+
appId: string;
|
|
60
|
+
scopeId: string;
|
|
61
|
+
/** The pinned plan. Read from the version the run points at, never the workflow's current one. */
|
|
62
|
+
plan: WorkflowBoundPlan;
|
|
63
|
+
inputs: Record<string, WorkflowJsonValue>;
|
|
64
|
+
context: Record<string, WorkflowJsonValue>;
|
|
65
|
+
authorization: WorkflowJsonValue;
|
|
66
|
+
/**
|
|
67
|
+
* The event's logical time, not the worker's clock. A replay after a crash
|
|
68
|
+
* must see the same "now" the first attempt did, or a step reading the date
|
|
69
|
+
* stops being a function of its inputs.
|
|
70
|
+
*/
|
|
71
|
+
occurredAt: Date;
|
|
72
|
+
parentRunId: string | null;
|
|
73
|
+
parentStepKey: string | null;
|
|
74
|
+
attempt: number;
|
|
75
|
+
consecutiveFailures: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The outcome a run settles into. `waiting` means it parked and will be woken. */
|
|
79
|
+
export type WorkflowRunResult =
|
|
80
|
+
| { state: "succeeded"; result?: WorkflowJsonValue; message?: string }
|
|
81
|
+
| { state: "failed"; error: WorkflowJsonValue }
|
|
82
|
+
| { state: "needs_attention"; error: WorkflowJsonValue }
|
|
83
|
+
| { state: "canceled"; message?: string }
|
|
84
|
+
| { state: "waiting" };
|
|
85
|
+
|
|
86
|
+
type ClaimRow = {
|
|
87
|
+
id: string;
|
|
88
|
+
execution_generation: string;
|
|
89
|
+
mode: WorkflowInvocationMode;
|
|
90
|
+
workflow_id: string;
|
|
91
|
+
workflow_version_id: string;
|
|
92
|
+
source_hash: string;
|
|
93
|
+
idempotency_key: string;
|
|
94
|
+
app_id: string;
|
|
95
|
+
scope_id: string;
|
|
96
|
+
plan: WorkflowBoundPlan;
|
|
97
|
+
inputs: Record<string, WorkflowJsonValue>;
|
|
98
|
+
context: Record<string, WorkflowJsonValue>;
|
|
99
|
+
authorization_snapshot: WorkflowJsonValue;
|
|
100
|
+
occurred_at: Date;
|
|
101
|
+
parent_run_id: string | null;
|
|
102
|
+
parent_step_key: string | null;
|
|
103
|
+
attempt: number;
|
|
104
|
+
consecutive_failures: number;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const toClaim = (row: ClaimRow): WorkflowRunClaim => ({
|
|
108
|
+
runId: row.id,
|
|
109
|
+
executionGeneration: Number(row.execution_generation),
|
|
110
|
+
mode: row.mode,
|
|
111
|
+
workflowId: row.workflow_id,
|
|
112
|
+
workflowVersionId: row.workflow_version_id,
|
|
113
|
+
sourceHash: row.source_hash,
|
|
114
|
+
idempotencyKey: row.idempotency_key,
|
|
115
|
+
appId: row.app_id,
|
|
116
|
+
scopeId: row.scope_id,
|
|
117
|
+
plan: row.plan,
|
|
118
|
+
inputs: row.inputs,
|
|
119
|
+
context: row.context,
|
|
120
|
+
authorization: row.authorization_snapshot,
|
|
121
|
+
occurredAt: row.occurred_at,
|
|
122
|
+
parentRunId: row.parent_run_id,
|
|
123
|
+
parentStepKey: row.parent_step_key,
|
|
124
|
+
attempt: row.attempt,
|
|
125
|
+
consecutiveFailures: row.consecutive_failures,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Why an update matched no rows.
|
|
130
|
+
*
|
|
131
|
+
* The distinction is not cosmetic: a cancelled run must stop, a stale one must
|
|
132
|
+
* abandon its work silently because someone else already owns it, and treating
|
|
133
|
+
* either as the other either duplicates effects or strands runs.
|
|
134
|
+
*/
|
|
135
|
+
const diagnoseLostLease = async (
|
|
136
|
+
db: SQL,
|
|
137
|
+
runId: string,
|
|
138
|
+
executionGeneration: number,
|
|
139
|
+
): Promise<{ state: "stale" } | { state: "canceled"; message?: string }> => {
|
|
140
|
+
const [row] = await db<{ state: WorkflowRunState; execution_generation: string; cancel_requested_at: Date | null }[]>`
|
|
141
|
+
SELECT state, execution_generation, cancel_requested_at FROM workflows.run WHERE id = ${runId}
|
|
142
|
+
`;
|
|
143
|
+
if (!row) return { state: "stale" };
|
|
144
|
+
const ours = Number(row.execution_generation) === executionGeneration;
|
|
145
|
+
if (ours && (row.cancel_requested_at !== null || row.state === "canceled")) return { state: "canceled" };
|
|
146
|
+
return { state: "stale" };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ─── Creation ────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export type NewWorkflowRun = {
|
|
152
|
+
appId: string;
|
|
153
|
+
scopeId: string;
|
|
154
|
+
workflowId: string;
|
|
155
|
+
workflowVersionId: string;
|
|
156
|
+
mode: WorkflowInvocationMode;
|
|
157
|
+
authorization: WorkflowJsonValue;
|
|
158
|
+
idempotencyKey: string;
|
|
159
|
+
occurredAt: Date;
|
|
160
|
+
inputs?: Record<string, WorkflowJsonValue>;
|
|
161
|
+
/** Read by the plan as context.*, alongside the inputs. */
|
|
162
|
+
context?: Record<string, WorkflowJsonValue>;
|
|
163
|
+
eventId?: string;
|
|
164
|
+
/** Set together, or the run is not a child of anything. */
|
|
165
|
+
parentRunId?: string;
|
|
166
|
+
parentStepKey?: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Queues a run, or returns the existing one for the same idempotency key.
|
|
171
|
+
*
|
|
172
|
+
* Returning the existing id rather than raising is what lets a retried request
|
|
173
|
+
* — a webhook redelivery, a double-clicked button — be answered with the run it
|
|
174
|
+
* already started instead of starting a second one.
|
|
175
|
+
*/
|
|
176
|
+
export const createWorkflowRun = async (run: NewWorkflowRun, options: { db?: SQL } = {}): Promise<string> => {
|
|
177
|
+
const db = options.db ?? sql;
|
|
178
|
+
const [row] = await db<{ id: string }[]>`
|
|
179
|
+
INSERT INTO workflows.run (
|
|
180
|
+
app_id, scope_id, workflow_id, workflow_version_id, event_id, parent_run_id, parent_step_key,
|
|
181
|
+
mode, inputs, context, authorization_snapshot, idempotency_key, occurred_at
|
|
182
|
+
)
|
|
183
|
+
SELECT
|
|
184
|
+
w.app_id, w.scope_id, w.id, v.id,
|
|
185
|
+
${run.eventId ?? null}::uuid, ${run.parentRunId ?? null}::uuid, ${run.parentStepKey ?? null},
|
|
186
|
+
${run.mode}, ${run.inputs ?? {}}, ${run.context ?? {}}, ${run.authorization},
|
|
187
|
+
${run.idempotencyKey}, ${run.occurredAt}
|
|
188
|
+
FROM workflows.workflow AS w
|
|
189
|
+
JOIN workflows.version AS v ON v.workflow_id = w.id
|
|
190
|
+
WHERE w.id = ${run.workflowId}::uuid
|
|
191
|
+
AND v.id = ${run.workflowVersionId}::uuid
|
|
192
|
+
AND w.app_id = ${run.appId}
|
|
193
|
+
AND w.scope_id = ${run.scopeId}
|
|
194
|
+
AND (
|
|
195
|
+
${run.parentRunId ?? null}::uuid IS NULL
|
|
196
|
+
OR EXISTS (
|
|
197
|
+
SELECT 1 FROM workflows.run AS parent
|
|
198
|
+
WHERE parent.id = ${run.parentRunId ?? null}::uuid
|
|
199
|
+
AND parent.app_id = w.app_id
|
|
200
|
+
AND parent.scope_id = w.scope_id
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
ON CONFLICT (workflow_id, mode, idempotency_key) DO UPDATE SET updated_at = workflows.run.updated_at
|
|
204
|
+
RETURNING id
|
|
205
|
+
`;
|
|
206
|
+
if (!row) throw new Error("workflow run does not match its workflow version, app, scope, or parent");
|
|
207
|
+
return row.id;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Fans out into child runs in one statement.
|
|
212
|
+
*
|
|
213
|
+
* A bulk operation over 10,000 records is 10,000 of these rather than rows in a
|
|
214
|
+
* targets table, so the parent's progress is a plain aggregate over its
|
|
215
|
+
* children and there is only one lease protocol to reason about.
|
|
216
|
+
*/
|
|
217
|
+
export const createChildWorkflowRuns = async (
|
|
218
|
+
parent: { runId: string; stepKey: string },
|
|
219
|
+
children: readonly NewWorkflowRun[],
|
|
220
|
+
options: { db?: SQL } = {},
|
|
221
|
+
): Promise<number> => {
|
|
222
|
+
if (children.length === 0) return 0;
|
|
223
|
+
const db = options.db ?? sql;
|
|
224
|
+
const values = children.map((child) => ({
|
|
225
|
+
app_id: child.appId,
|
|
226
|
+
scope_id: child.scopeId,
|
|
227
|
+
workflow_id: child.workflowId,
|
|
228
|
+
workflow_version_id: child.workflowVersionId,
|
|
229
|
+
event_id: child.eventId ?? null,
|
|
230
|
+
parent_run_id: parent.runId,
|
|
231
|
+
parent_step_key: parent.stepKey,
|
|
232
|
+
mode: child.mode,
|
|
233
|
+
inputs: child.inputs ?? {},
|
|
234
|
+
context: child.context ?? {},
|
|
235
|
+
authorization_snapshot: child.authorization,
|
|
236
|
+
idempotency_key: child.idempotencyKey,
|
|
237
|
+
occurred_at: child.occurredAt,
|
|
238
|
+
}));
|
|
239
|
+
const rows = await db<{ id: string }[]>`
|
|
240
|
+
INSERT INTO workflows.run ${db(values)}
|
|
241
|
+
ON CONFLICT (workflow_id, mode, idempotency_key) DO NOTHING
|
|
242
|
+
RETURNING id
|
|
243
|
+
`;
|
|
244
|
+
return rows.length;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** How a parent's fan-out is doing, as one aggregate over the child index. */
|
|
248
|
+
export const countChildWorkflowRuns = async (
|
|
249
|
+
parentRunId: string,
|
|
250
|
+
options: { db?: SQL } = {},
|
|
251
|
+
): Promise<Record<WorkflowRunState, number>> => {
|
|
252
|
+
const db = options.db ?? sql;
|
|
253
|
+
const rows = await db<{ state: WorkflowRunState; count: string }[]>`
|
|
254
|
+
SELECT state, count(*) AS count FROM workflows.run WHERE parent_run_id = ${parentRunId}::uuid GROUP BY state
|
|
255
|
+
`;
|
|
256
|
+
const counts = { queued: 0, running: 0, waiting: 0, succeeded: 0, failed: 0, canceled: 0, needs_attention: 0 };
|
|
257
|
+
for (const row of rows) counts[row.state] = Number(row.count);
|
|
258
|
+
return counts;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ─── Lifecycle ───────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Takes the next claimable run, or a named one.
|
|
265
|
+
*
|
|
266
|
+
* `SKIP LOCKED` is what makes several workers safe to run against the same
|
|
267
|
+
* queue: they step over each other's candidates instead of serialising on the
|
|
268
|
+
* first row.
|
|
269
|
+
*/
|
|
270
|
+
export const claimWorkflowRun = async (options: {
|
|
271
|
+
worker: string;
|
|
272
|
+
runId?: string;
|
|
273
|
+
/** Restricts a worker to one app's runs, so a per-app process cannot drain another's. */
|
|
274
|
+
appId?: string;
|
|
275
|
+
/**
|
|
276
|
+
* Which mode to claim. Defaults to `execute`.
|
|
277
|
+
*
|
|
278
|
+
* A dry run asks what *would* happen; claiming one and running it through the
|
|
279
|
+
* execute path performs the effects it was meant to only describe.
|
|
280
|
+
*/
|
|
281
|
+
mode?: WorkflowInvocationMode;
|
|
282
|
+
leaseMs?: number;
|
|
283
|
+
db?: SQL;
|
|
284
|
+
}): Promise<WorkflowRunClaim | null> => {
|
|
285
|
+
const db = options.db ?? sql;
|
|
286
|
+
const leaseMs = options.leaseMs ?? WORKFLOW_RUN_LEASE_MS;
|
|
287
|
+
const rows = await db<ClaimRow[]>`
|
|
288
|
+
WITH candidate AS (
|
|
289
|
+
SELECT id FROM workflows.run
|
|
290
|
+
WHERE state IN ('queued', 'running')
|
|
291
|
+
AND claimable_at < now()
|
|
292
|
+
AND cancel_requested_at IS NULL
|
|
293
|
+
AND (${options.runId ?? null}::uuid IS NULL OR id = ${options.runId ?? null}::uuid)
|
|
294
|
+
AND (${options.appId ?? null}::text IS NULL OR app_id = ${options.appId ?? null})
|
|
295
|
+
AND mode = ${options.mode ?? "execute"}
|
|
296
|
+
ORDER BY claimable_at, created_at, id
|
|
297
|
+
LIMIT 1
|
|
298
|
+
FOR UPDATE SKIP LOCKED
|
|
299
|
+
)
|
|
300
|
+
UPDATE workflows.run AS r
|
|
301
|
+
SET state = 'running',
|
|
302
|
+
execution_generation = r.execution_generation + 1,
|
|
303
|
+
attempt = r.attempt + 1,
|
|
304
|
+
lease_owner = ${options.worker},
|
|
305
|
+
lease_expires_at = now() + ${`${leaseMs} milliseconds`}::interval,
|
|
306
|
+
retry_after = NULL,
|
|
307
|
+
started_at = COALESCE(r.started_at, now()),
|
|
308
|
+
updated_at = now()
|
|
309
|
+
FROM candidate AS c
|
|
310
|
+
JOIN workflows.version AS v ON v.id = (SELECT workflow_version_id FROM workflows.run WHERE id = c.id)
|
|
311
|
+
WHERE r.id = c.id
|
|
312
|
+
RETURNING r.id, r.execution_generation, r.mode, r.workflow_id, r.workflow_version_id,
|
|
313
|
+
v.source_hash, r.idempotency_key, r.app_id, r.scope_id, v.plan,
|
|
314
|
+
r.inputs, r.context, r.authorization_snapshot, r.occurred_at, r.parent_run_id, r.parent_step_key,
|
|
315
|
+
r.attempt, r.consecutive_failures
|
|
316
|
+
`;
|
|
317
|
+
const row = rows[0];
|
|
318
|
+
return row ? toClaim(row) : null;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/** Extends a lease. Returns why it could not be extended, rather than throwing. */
|
|
322
|
+
export const renewWorkflowRunLease = async (
|
|
323
|
+
claim: { runId: string; executionGeneration: number },
|
|
324
|
+
options: { leaseMs?: number; db?: SQL } = {},
|
|
325
|
+
): Promise<WorkflowCoordinatorLeaseState> => {
|
|
326
|
+
const db = options.db ?? sql;
|
|
327
|
+
const leaseMs = options.leaseMs ?? WORKFLOW_RUN_LEASE_MS;
|
|
328
|
+
const rows = await db<{ id: string }[]>`
|
|
329
|
+
UPDATE workflows.run
|
|
330
|
+
SET lease_expires_at = now() + ${`${leaseMs} milliseconds`}::interval, updated_at = now()
|
|
331
|
+
WHERE id = ${claim.runId}
|
|
332
|
+
AND execution_generation = ${claim.executionGeneration}
|
|
333
|
+
AND state = 'running'
|
|
334
|
+
AND cancel_requested_at IS NULL
|
|
335
|
+
RETURNING id
|
|
336
|
+
`;
|
|
337
|
+
if (rows.length > 0) return { state: "active" };
|
|
338
|
+
return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Records a run's outcome and drops the lease.
|
|
343
|
+
*
|
|
344
|
+
* A `waiting` result is the exception: the run parked itself in `parkStep`, so
|
|
345
|
+
* this only confirms it is where the executor believes it is. Writing the state
|
|
346
|
+
* a second time would clear the wake deadline the park just set.
|
|
347
|
+
*/
|
|
348
|
+
export const finishWorkflowRun = async (
|
|
349
|
+
claim: { runId: string; executionGeneration: number },
|
|
350
|
+
result: WorkflowRunResult,
|
|
351
|
+
options: { db?: SQL } = {},
|
|
352
|
+
): Promise<WorkflowCoordinatorFinishState> => {
|
|
353
|
+
const db = options.db ?? sql;
|
|
354
|
+
|
|
355
|
+
if (result.state === "waiting") {
|
|
356
|
+
// A dependency signal may have arrived between parkStep and this
|
|
357
|
+
// coordinator acknowledgement. In that case parkStep already re-queued
|
|
358
|
+
// the run; the waiting attempt still finished successfully.
|
|
359
|
+
const rows = await db<{ id: string }[]>`
|
|
360
|
+
UPDATE workflows.run
|
|
361
|
+
SET consecutive_failures = 0, updated_at = now()
|
|
362
|
+
WHERE id = ${claim.runId}
|
|
363
|
+
AND execution_generation = ${claim.executionGeneration}
|
|
364
|
+
AND state IN ('waiting', 'queued')
|
|
365
|
+
RETURNING id
|
|
366
|
+
`;
|
|
367
|
+
if (rows.length > 0) return { state: "finished" };
|
|
368
|
+
return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const serializedResult = result.state === "succeeded" ? JSON.stringify(result.result ?? null) : null;
|
|
372
|
+
|
|
373
|
+
const rows = await db<{ id: string }[]>`
|
|
374
|
+
UPDATE workflows.run
|
|
375
|
+
SET state = ${result.state},
|
|
376
|
+
result = (${serializedResult}::text)::jsonb,
|
|
377
|
+
result_message = ${(result.state === "succeeded" || result.state === "canceled" ? result.message : null) ?? null},
|
|
378
|
+
error = ${result.state === "failed" || result.state === "needs_attention" ? result.error : null},
|
|
379
|
+
lease_owner = NULL,
|
|
380
|
+
lease_expires_at = NULL,
|
|
381
|
+
retry_after = NULL,
|
|
382
|
+
consecutive_failures = 0,
|
|
383
|
+
wake_at = NULL,
|
|
384
|
+
finished_at = now(),
|
|
385
|
+
updated_at = now()
|
|
386
|
+
WHERE id = ${claim.runId}
|
|
387
|
+
AND execution_generation = ${claim.executionGeneration}
|
|
388
|
+
AND state IN ('running', 'waiting')
|
|
389
|
+
RETURNING id
|
|
390
|
+
`;
|
|
391
|
+
if (rows.length > 0) return { state: "finished" };
|
|
392
|
+
return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Gives up a lease without recording an outcome — the crash path.
|
|
397
|
+
*
|
|
398
|
+
* The run goes back to `queued` and the journal makes the resumed attempt skip
|
|
399
|
+
* whatever already completed. The backoff exists because a run that dies on
|
|
400
|
+
* every attempt would otherwise be re-claimed as fast as the dispatcher loops.
|
|
401
|
+
*/
|
|
402
|
+
export const releaseWorkflowRun = async (
|
|
403
|
+
claim: { runId: string; executionGeneration: number; consecutiveFailures?: number },
|
|
404
|
+
options: { backoffMs?: number; db?: SQL } = {},
|
|
405
|
+
): Promise<WorkflowCoordinatorReleaseState> => {
|
|
406
|
+
const db = options.db ?? sql;
|
|
407
|
+
const backoffMs =
|
|
408
|
+
options.backoffMs ?? Math.min(RELEASE_BACKOFF_MS * 2 ** Math.max(0, claim.consecutiveFailures ?? 0), RELEASE_MAX_BACKOFF_MS);
|
|
409
|
+
const rows = await db<{ retry_after: Date }[]>`
|
|
410
|
+
UPDATE workflows.run
|
|
411
|
+
SET state = 'queued',
|
|
412
|
+
lease_owner = NULL,
|
|
413
|
+
lease_expires_at = NULL,
|
|
414
|
+
retry_after = now() + ${`${backoffMs} milliseconds`}::interval,
|
|
415
|
+
consecutive_failures = consecutive_failures + 1,
|
|
416
|
+
updated_at = now()
|
|
417
|
+
WHERE id = ${claim.runId}
|
|
418
|
+
AND execution_generation = ${claim.executionGeneration}
|
|
419
|
+
AND state = 'running'
|
|
420
|
+
RETURNING retry_after
|
|
421
|
+
`;
|
|
422
|
+
const row = rows[0];
|
|
423
|
+
if (row) return { state: "retry", retryAt: row.retry_after.toISOString() };
|
|
424
|
+
return diagnoseLostLease(db, claim.runId, claim.executionGeneration);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/** Asks a run to stop. The worker notices on its next heartbeat. */
|
|
428
|
+
export const requestWorkflowRunCancel = async (runId: string, options: { db?: SQL } = {}): Promise<boolean> => {
|
|
429
|
+
const db = options.db ?? sql;
|
|
430
|
+
const rows = await db<{ id: string }[]>`
|
|
431
|
+
WITH RECURSIVE descendants AS (
|
|
432
|
+
SELECT id FROM workflows.run WHERE id = ${runId}::uuid
|
|
433
|
+
UNION ALL
|
|
434
|
+
SELECT child.id
|
|
435
|
+
FROM workflows.run AS child
|
|
436
|
+
JOIN descendants AS parent ON child.parent_run_id = parent.id
|
|
437
|
+
)
|
|
438
|
+
UPDATE workflows.run AS r
|
|
439
|
+
SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
|
|
440
|
+
state = CASE WHEN state IN ('queued', 'waiting') THEN 'canceled' ELSE state END,
|
|
441
|
+
finished_at = CASE WHEN state IN ('queued', 'waiting') THEN now() ELSE finished_at END,
|
|
442
|
+
lease_owner = CASE WHEN state IN ('queued', 'waiting') THEN NULL ELSE lease_owner END,
|
|
443
|
+
lease_expires_at = CASE WHEN state IN ('queued', 'waiting') THEN NULL ELSE lease_expires_at END,
|
|
444
|
+
wake_at = CASE WHEN state IN ('queued', 'waiting') THEN NULL ELSE wake_at END,
|
|
445
|
+
updated_at = now()
|
|
446
|
+
WHERE r.id IN (SELECT id FROM descendants)
|
|
447
|
+
AND r.state NOT IN ${db(TERMINAL_RUN_STATES)}
|
|
448
|
+
RETURNING r.id
|
|
449
|
+
`;
|
|
450
|
+
return rows.some((row) => row.id === runId);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const canceledWithUncertainEffect = {
|
|
454
|
+
code: "WORKFLOW_EFFECT_OUTCOME_UNKNOWN",
|
|
455
|
+
message: "The workflow was canceled while an external effect was still unresolved.",
|
|
456
|
+
retryable: false,
|
|
457
|
+
} as const;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Settles a cancellation owned by one worker generation.
|
|
461
|
+
*
|
|
462
|
+
* A canceled run with no uncertain effect is terminal. An effect that may have
|
|
463
|
+
* escaped remains an operator decision instead: calling that run canceled would
|
|
464
|
+
* hide the exact duplicate-effect risk the journal exists to expose.
|
|
465
|
+
*/
|
|
466
|
+
export const finishWorkflowRunCancel = async (
|
|
467
|
+
claim: { runId: string; executionGeneration: number },
|
|
468
|
+
options: { db?: SQL } = {},
|
|
469
|
+
): Promise<{ state: "finished"; result: Extract<WorkflowRunResult, { state: "canceled" | "needs_attention" }> } | { state: "stale" }> => {
|
|
470
|
+
const db = options.db ?? sql;
|
|
471
|
+
return withTransaction(db, async (tx) => {
|
|
472
|
+
const [run] = await tx<{ id: string }[]>`
|
|
473
|
+
SELECT id FROM workflows.run
|
|
474
|
+
WHERE id = ${claim.runId}::uuid
|
|
475
|
+
AND execution_generation = ${claim.executionGeneration}
|
|
476
|
+
AND state = 'running'
|
|
477
|
+
AND cancel_requested_at IS NOT NULL
|
|
478
|
+
FOR UPDATE
|
|
479
|
+
`;
|
|
480
|
+
if (!run) return { state: "stale" } as const;
|
|
481
|
+
|
|
482
|
+
const unsettled = await tx<{ step_key: string }[]>`
|
|
483
|
+
UPDATE workflows.step_outcome
|
|
484
|
+
SET state = 'needs_attention',
|
|
485
|
+
outcome = ${{ mode: "execute", outcome: { state: "needs_attention", error: canceledWithUncertainEffect } }},
|
|
486
|
+
dependency = NULL,
|
|
487
|
+
finished_at = now(),
|
|
488
|
+
updated_at = now()
|
|
489
|
+
WHERE run_id = ${claim.runId}::uuid
|
|
490
|
+
AND effect_state IN ('executing', 'ambiguous')
|
|
491
|
+
RETURNING step_key
|
|
492
|
+
`;
|
|
493
|
+
const result =
|
|
494
|
+
unsettled.length > 0 ? ({ state: "needs_attention", error: canceledWithUncertainEffect } as const) : ({ state: "canceled" } as const);
|
|
495
|
+
await tx`
|
|
496
|
+
UPDATE workflows.run
|
|
497
|
+
SET state = ${result.state},
|
|
498
|
+
result = NULL,
|
|
499
|
+
result_message = NULL,
|
|
500
|
+
error = ${result.state === "needs_attention" ? result.error : null},
|
|
501
|
+
lease_owner = NULL,
|
|
502
|
+
lease_expires_at = NULL,
|
|
503
|
+
retry_after = NULL,
|
|
504
|
+
wake_at = NULL,
|
|
505
|
+
finished_at = now(),
|
|
506
|
+
updated_at = now()
|
|
507
|
+
WHERE id = ${claim.runId}::uuid
|
|
508
|
+
AND execution_generation = ${claim.executionGeneration}
|
|
509
|
+
AND state = 'running'
|
|
510
|
+
`;
|
|
511
|
+
return { state: "finished", result } as const;
|
|
512
|
+
});
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/** Finalizes canceled runs whose worker disappeared before observing the request. */
|
|
516
|
+
export const finishExpiredWorkflowRunCancels = async (limit: number, options: { appId: string; db?: SQL }): Promise<number> => {
|
|
517
|
+
const db = options.db ?? sql;
|
|
518
|
+
return withTransaction(db, async (tx) => {
|
|
519
|
+
const rows = await tx<{ id: string; execution_generation: string }[]>`
|
|
520
|
+
SELECT id, execution_generation
|
|
521
|
+
FROM workflows.run
|
|
522
|
+
WHERE app_id = ${options.appId}
|
|
523
|
+
AND state = 'running'
|
|
524
|
+
AND cancel_requested_at IS NOT NULL
|
|
525
|
+
AND lease_expires_at < now()
|
|
526
|
+
ORDER BY lease_expires_at, id
|
|
527
|
+
LIMIT ${limit}
|
|
528
|
+
FOR UPDATE SKIP LOCKED
|
|
529
|
+
`;
|
|
530
|
+
for (const row of rows) {
|
|
531
|
+
await finishWorkflowRunCancel({ runId: row.id, executionGeneration: Number(row.execution_generation) }, { db: tx });
|
|
532
|
+
}
|
|
533
|
+
return rows.length;
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/** The coordinator's view of the store, so `coordinateWorkflowExecution` drives it directly. */
|
|
538
|
+
export const createWorkflowCoordinatorPort = (options: {
|
|
539
|
+
worker: string;
|
|
540
|
+
runId?: string;
|
|
541
|
+
appId: string;
|
|
542
|
+
mode?: WorkflowInvocationMode;
|
|
543
|
+
leaseMs?: number;
|
|
544
|
+
}): WorkflowCoordinatorPort<void, WorkflowRunClaim, WorkflowRunResult> => ({
|
|
545
|
+
claim: () => claimWorkflowRun(options),
|
|
546
|
+
renew: (claim) => renewWorkflowRunLease(claim, { leaseMs: options.leaseMs }),
|
|
547
|
+
finish: (claim, result) => finishWorkflowRun(claim, result),
|
|
548
|
+
release: (claim) => releaseWorkflowRun(claim),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// ─── Scans ───────────────────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
/** Runs a worker may pick up now, oldest first. */
|
|
554
|
+
export const listClaimableWorkflowRunIds = async (limit: number, options: { db?: SQL } = {}): Promise<string[]> => {
|
|
555
|
+
const db = options.db ?? sql;
|
|
556
|
+
const rows = await db<{ id: string }[]>`
|
|
557
|
+
SELECT id FROM workflows.run
|
|
558
|
+
WHERE state IN ('queued', 'running')
|
|
559
|
+
AND claimable_at < now()
|
|
560
|
+
AND cancel_requested_at IS NULL
|
|
561
|
+
ORDER BY claimable_at, created_at, id
|
|
562
|
+
LIMIT ${limit}
|
|
563
|
+
`;
|
|
564
|
+
return rows.map((row) => row.id);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/** Parked runs whose deadline has passed, so a dependency that never fires cannot strand them. */
|
|
568
|
+
export const wakeExpiredWorkflowRuns = async (limit: number, options: { appId?: string; db?: SQL } = {}): Promise<string[]> => {
|
|
569
|
+
const db = options.db ?? sql;
|
|
570
|
+
const rows = await db<{ id: string }[]>`
|
|
571
|
+
UPDATE workflows.run
|
|
572
|
+
SET state = 'queued', wake_at = NULL, updated_at = now()
|
|
573
|
+
WHERE id IN (
|
|
574
|
+
SELECT id FROM workflows.run
|
|
575
|
+
WHERE state = 'waiting' AND wake_at IS NOT NULL AND wake_at <= now()
|
|
576
|
+
AND (${options.appId ?? null}::text IS NULL OR app_id = ${options.appId ?? null})
|
|
577
|
+
ORDER BY wake_at, id
|
|
578
|
+
LIMIT ${limit}
|
|
579
|
+
FOR UPDATE SKIP LOCKED
|
|
580
|
+
)
|
|
581
|
+
RETURNING id
|
|
582
|
+
`;
|
|
583
|
+
return rows.map((row) => row.id);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/** Re-queues every run parked on a dependency that has now been satisfied. */
|
|
587
|
+
export const wakeWorkflowRunsWaitingOn = async (
|
|
588
|
+
dependency: { appId: string; kind: string; key: string },
|
|
589
|
+
options: { db?: SQL } = {},
|
|
590
|
+
): Promise<string[]> => {
|
|
591
|
+
return withTransaction(options.db, async (tx) => {
|
|
592
|
+
await tx`
|
|
593
|
+
INSERT INTO workflows.dependency_signal (app_id, kind, key)
|
|
594
|
+
VALUES (${dependency.appId}, ${dependency.kind}, ${dependency.key})
|
|
595
|
+
ON CONFLICT (app_id, kind, key) DO UPDATE
|
|
596
|
+
SET updated_at = now()
|
|
597
|
+
`;
|
|
598
|
+
const rows = await tx<{ id: string }[]>`
|
|
599
|
+
UPDATE workflows.run AS r
|
|
600
|
+
SET state = 'queued', wake_at = NULL, updated_at = now()
|
|
601
|
+
FROM workflows.step_outcome AS s
|
|
602
|
+
WHERE s.run_id = r.id
|
|
603
|
+
AND s.state = 'waiting'
|
|
604
|
+
AND s.dependency ->> 'kind' = ${dependency.kind}
|
|
605
|
+
AND s.dependency ->> 'key' = ${dependency.key}
|
|
606
|
+
AND r.app_id = ${dependency.appId}
|
|
607
|
+
AND r.state = 'waiting'
|
|
608
|
+
RETURNING r.id
|
|
609
|
+
`;
|
|
610
|
+
return rows.map((row) => row.id).sort();
|
|
611
|
+
});
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// ─── Journal ─────────────────────────────────────────────────────────────────
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Thrown when a step writes under a generation the run has moved past.
|
|
618
|
+
*
|
|
619
|
+
* Deliberately the executor's own class rather than a second one with the same
|
|
620
|
+
* name: the executor rethrows this and turns every *other* error into an action
|
|
621
|
+
* failure, so a private copy would have a lost lease recorded as a step that
|
|
622
|
+
* failed — on a run some other worker now owns.
|
|
623
|
+
*/
|
|
624
|
+
export { WorkflowLeaseLostError } from "../runtime/executor";
|
|
625
|
+
|
|
626
|
+
/** The `state` column mirrors the outcome's own discriminant — no second vocabulary. */
|
|
627
|
+
const stepState = (result: WorkflowRuntimeStepResult): string => result.outcome.state;
|
|
628
|
+
|
|
629
|
+
const stepJournal = (options: { db?: SQL } = {}): WorkflowRuntimeRepositoryPort => {
|
|
630
|
+
const db = options.db ?? sql;
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Every journal write is fenced against the *run's* current generation, not
|
|
634
|
+
* the generation recorded on the step row. Matching the step's own value let
|
|
635
|
+
* a worker whose lease had lapsed still record an outcome, on a run someone
|
|
636
|
+
* else already owned — the step it had half-finished then looked complete to
|
|
637
|
+
* the worker that took over.
|
|
638
|
+
*/
|
|
639
|
+
const startStep = async (step: WorkflowRuntimeStepIdentity): Promise<void> => {
|
|
640
|
+
const rows = await db<{ run_id: string }[]>`
|
|
641
|
+
INSERT INTO workflows.step_outcome (
|
|
642
|
+
run_id, step_key, source_path, iteration_path, kind, action, mode, state, execution_generation, attempt
|
|
643
|
+
)
|
|
644
|
+
SELECT ${step.runId}::uuid, ${step.key}, ${step.sourcePath}, ${step.iterationPath},
|
|
645
|
+
${step.kind}, ${step.action ?? null}, ${step.mode}, 'running', ${step.executionGeneration}, 0
|
|
646
|
+
WHERE EXISTS (
|
|
647
|
+
SELECT 1 FROM workflows.run
|
|
648
|
+
WHERE id = ${step.runId}::uuid
|
|
649
|
+
AND state = 'running'
|
|
650
|
+
AND execution_generation = ${step.executionGeneration}
|
|
651
|
+
AND cancel_requested_at IS NULL
|
|
652
|
+
)
|
|
653
|
+
ON CONFLICT (run_id, step_key) DO UPDATE
|
|
654
|
+
SET state = 'running',
|
|
655
|
+
outcome = NULL,
|
|
656
|
+
dependency = NULL,
|
|
657
|
+
execution_generation = EXCLUDED.execution_generation,
|
|
658
|
+
attempt = workflows.step_outcome.attempt + 1,
|
|
659
|
+
started_at = now(),
|
|
660
|
+
finished_at = NULL,
|
|
661
|
+
updated_at = now()
|
|
662
|
+
RETURNING run_id
|
|
663
|
+
`;
|
|
664
|
+
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Parks a step on something outside the run, and the run with it.
|
|
669
|
+
*
|
|
670
|
+
* Both happen in one transaction: a step marked waiting while its run stays
|
|
671
|
+
* `running` is a run nothing will ever wake, and a run marked waiting with no
|
|
672
|
+
* recorded dependency is one nothing knows how to wake.
|
|
673
|
+
*/
|
|
674
|
+
const parkStep = async (step: WorkflowRuntimeStepIdentity, dependency: WorkflowDependency): Promise<void> => {
|
|
675
|
+
await withTransaction(options.db, async (tx) => {
|
|
676
|
+
const rows = await tx<{ run_id: string }[]>`
|
|
677
|
+
UPDATE workflows.step_outcome AS s
|
|
678
|
+
SET state = 'waiting', outcome = NULL, dependency = ${dependency}, finished_at = NULL, updated_at = now()
|
|
679
|
+
FROM workflows.run AS r
|
|
680
|
+
WHERE s.run_id = ${step.runId}::uuid
|
|
681
|
+
AND s.step_key = ${step.key}
|
|
682
|
+
AND r.id = s.run_id
|
|
683
|
+
AND r.state = 'running'
|
|
684
|
+
AND r.execution_generation = ${step.executionGeneration}
|
|
685
|
+
RETURNING s.run_id
|
|
686
|
+
`;
|
|
687
|
+
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
688
|
+
|
|
689
|
+
const parked = await tx<{ id: string; app_id: string }[]>`
|
|
690
|
+
UPDATE workflows.run
|
|
691
|
+
SET state = 'waiting',
|
|
692
|
+
wake_at = ${dependency.deadline ?? null}::timestamptz,
|
|
693
|
+
lease_owner = NULL,
|
|
694
|
+
lease_expires_at = NULL,
|
|
695
|
+
updated_at = now()
|
|
696
|
+
WHERE id = ${step.runId}::uuid AND execution_generation = ${step.executionGeneration} AND state = 'running'
|
|
697
|
+
RETURNING id, app_id
|
|
698
|
+
`;
|
|
699
|
+
const run = parked[0];
|
|
700
|
+
if (!run) throw new WorkflowLeaseLostError();
|
|
701
|
+
|
|
702
|
+
// Close wake-before-park: a signal recorded just before this transaction
|
|
703
|
+
// is as satisfying as one recorded just after it.
|
|
704
|
+
const [signal] = await tx<{ app_id: string }[]>`
|
|
705
|
+
SELECT app_id FROM workflows.dependency_signal
|
|
706
|
+
WHERE app_id = ${run.app_id}
|
|
707
|
+
AND kind = ${dependency.kind}
|
|
708
|
+
AND key = ${dependency.key}
|
|
709
|
+
`;
|
|
710
|
+
if (signal) {
|
|
711
|
+
await tx`
|
|
712
|
+
UPDATE workflows.run
|
|
713
|
+
SET state = 'queued', wake_at = NULL, updated_at = now()
|
|
714
|
+
WHERE id = ${step.runId}::uuid
|
|
715
|
+
AND execution_generation = ${step.executionGeneration}
|
|
716
|
+
AND state = 'waiting'
|
|
717
|
+
`;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const finishStep = async (step: WorkflowRuntimeStepIdentity, result: WorkflowRuntimeStepResult): Promise<void> => {
|
|
723
|
+
// The port's type admits a waiting outcome here; parking is the path that
|
|
724
|
+
// also stops the run, so route it there rather than writing half of it.
|
|
725
|
+
if (result.mode === "execute" && result.outcome.state === "waiting") {
|
|
726
|
+
await parkStep(step, result.outcome.dependency);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const rows = await db<{ run_id: string }[]>`
|
|
730
|
+
UPDATE workflows.step_outcome AS s
|
|
731
|
+
SET state = ${stepState(result)},
|
|
732
|
+
outcome = ${result},
|
|
733
|
+
dependency = NULL,
|
|
734
|
+
finished_at = now(),
|
|
735
|
+
updated_at = now()
|
|
736
|
+
FROM workflows.run AS r
|
|
737
|
+
WHERE s.run_id = ${step.runId}::uuid
|
|
738
|
+
AND s.step_key = ${step.key}
|
|
739
|
+
AND r.id = s.run_id
|
|
740
|
+
AND r.state = 'running'
|
|
741
|
+
AND r.execution_generation = ${step.executionGeneration}
|
|
742
|
+
RETURNING s.run_id
|
|
743
|
+
`;
|
|
744
|
+
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const restoreStepOutcome = async (step: WorkflowRuntimeStepIdentity): Promise<WorkflowRestoredStep | null> => {
|
|
748
|
+
const [row] = await db<{ outcome: WorkflowRestoredStep | null }[]>`
|
|
749
|
+
SELECT outcome FROM workflows.step_outcome
|
|
750
|
+
WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key} AND state NOT IN ('running', 'waiting')
|
|
751
|
+
`;
|
|
752
|
+
return row?.outcome ?? null;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const heartbeat = async (run: WorkflowRuntimeRunIdentity): Promise<WorkflowHeartbeatOutcome> =>
|
|
756
|
+
renewWorkflowRunLease({ runId: run.runId, executionGeneration: run.executionGeneration }, { db });
|
|
757
|
+
|
|
758
|
+
return { heartbeat, restoreStepOutcome, startStep, finishStep, parkStep };
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The kernel's `WorkflowRuntimeRepositoryPort`.
|
|
763
|
+
*
|
|
764
|
+
* There is one of these now. Each app used to bring its own, which is how the
|
|
765
|
+
* two ended up storing different things under the same column name — Grids
|
|
766
|
+
* wrote the bare outcome and reconstructed the mode from a second column, Mail
|
|
767
|
+
* wrote the pair.
|
|
768
|
+
*/
|
|
769
|
+
export const createWorkflowRuntimeRepository = (options: { db?: SQL } = {}): WorkflowRuntimeRepositoryPort => stepJournal(options);
|
|
770
|
+
|
|
771
|
+
// ─── Effects ─────────────────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Marks an impure step as having started its effect.
|
|
775
|
+
*
|
|
776
|
+
* Written before the effect is attempted, so a crash in between leaves the
|
|
777
|
+
* evidence that something may have escaped. A step whose effect is left
|
|
778
|
+
* `executing` is exactly the queue a replay has to be careful with.
|
|
779
|
+
*/
|
|
780
|
+
export const beginWorkflowEffect = async (
|
|
781
|
+
step: { runId: string; key: string; executionGeneration: number },
|
|
782
|
+
effectKey: string,
|
|
783
|
+
options: { db?: SQL } = {},
|
|
784
|
+
): Promise<void> => {
|
|
785
|
+
const db = options.db ?? sql;
|
|
786
|
+
const rows = await db<{ run_id: string }[]>`
|
|
787
|
+
UPDATE workflows.step_outcome AS s
|
|
788
|
+
SET effect_key = ${effectKey},
|
|
789
|
+
effect_state = 'executing',
|
|
790
|
+
effect_started_at = COALESCE(s.effect_started_at, now()),
|
|
791
|
+
updated_at = now()
|
|
792
|
+
FROM workflows.run AS r
|
|
793
|
+
WHERE s.run_id = ${step.runId}::uuid
|
|
794
|
+
AND s.step_key = ${step.key}
|
|
795
|
+
AND r.id = s.run_id
|
|
796
|
+
AND r.state = 'running'
|
|
797
|
+
AND r.execution_generation = ${step.executionGeneration}
|
|
798
|
+
RETURNING s.run_id
|
|
799
|
+
`;
|
|
800
|
+
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Records a completed effect and what it produced, on a caller-supplied handle.
|
|
805
|
+
*
|
|
806
|
+
* Called with the transaction that performed the work, so the evidence commits
|
|
807
|
+
* with it: that is what lets a transactional action promise that a crash means
|
|
808
|
+
* it did not happen.
|
|
809
|
+
*/
|
|
810
|
+
export const recordWorkflowEffect = async (
|
|
811
|
+
db: SQL,
|
|
812
|
+
step: { runId: string; key: string; executionGeneration: number },
|
|
813
|
+
effectKey: string,
|
|
814
|
+
output: WorkflowJsonValue,
|
|
815
|
+
): Promise<void> => {
|
|
816
|
+
const rows = await db<{ run_id: string }[]>`
|
|
817
|
+
UPDATE workflows.step_outcome AS s
|
|
818
|
+
SET effect_key = ${effectKey},
|
|
819
|
+
effect_state = 'succeeded',
|
|
820
|
+
effect_output = ${output},
|
|
821
|
+
effect_started_at = COALESCE(s.effect_started_at, now()),
|
|
822
|
+
updated_at = now()
|
|
823
|
+
FROM workflows.run AS r
|
|
824
|
+
WHERE s.run_id = ${step.runId}::uuid
|
|
825
|
+
AND s.step_key = ${step.key}
|
|
826
|
+
AND r.id = s.run_id
|
|
827
|
+
AND r.state = 'running'
|
|
828
|
+
AND r.execution_generation = ${step.executionGeneration}
|
|
829
|
+
RETURNING s.run_id
|
|
830
|
+
`;
|
|
831
|
+
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
/** Settles an effect once its fate is known. `ambiguous` is a real answer, not a failure. */
|
|
835
|
+
export const settleWorkflowEffect = async (
|
|
836
|
+
step: { runId: string; key: string; executionGeneration: number },
|
|
837
|
+
state: "succeeded" | "ambiguous" | "failed",
|
|
838
|
+
options: { db?: SQL } = {},
|
|
839
|
+
): Promise<void> => {
|
|
840
|
+
const db = options.db ?? sql;
|
|
841
|
+
const rows = await db<{ run_id: string }[]>`
|
|
842
|
+
UPDATE workflows.step_outcome AS s
|
|
843
|
+
SET effect_state = ${state}, updated_at = now()
|
|
844
|
+
FROM workflows.run AS r
|
|
845
|
+
WHERE s.run_id = ${step.runId}::uuid
|
|
846
|
+
AND s.step_key = ${step.key}
|
|
847
|
+
AND s.effect_key IS NOT NULL
|
|
848
|
+
AND r.id = s.run_id
|
|
849
|
+
AND r.state = 'running'
|
|
850
|
+
AND r.execution_generation = ${step.executionGeneration}
|
|
851
|
+
RETURNING s.run_id
|
|
852
|
+
`;
|
|
853
|
+
if (rows.length === 0) throw new WorkflowLeaseLostError();
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
export type WorkflowAttentionResolution =
|
|
857
|
+
| { state: "succeeded"; output?: WorkflowJsonValue }
|
|
858
|
+
| { state: "failed"; message: string; code?: string };
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Resolves the one effect a run stopped for.
|
|
862
|
+
*
|
|
863
|
+
* The run must already be terminal needs_attention, so this cannot race a
|
|
864
|
+
* worker. Confirming success records the step outcome and re-queues the rest
|
|
865
|
+
* of the immutable plan; confirming failure settles both step and run.
|
|
866
|
+
*/
|
|
867
|
+
export const resolveWorkflowRunAttention = async (
|
|
868
|
+
input: { runId: string; stepKey: string; resolution: WorkflowAttentionResolution },
|
|
869
|
+
options: { db?: SQL } = {},
|
|
870
|
+
): Promise<void> => {
|
|
871
|
+
await withTransaction(options.db, async (tx) => {
|
|
872
|
+
const [row] = await tx<{ effect_state: string | null }[]>`
|
|
873
|
+
SELECT s.effect_state
|
|
874
|
+
FROM workflows.run AS r
|
|
875
|
+
JOIN workflows.step_outcome AS s ON s.run_id = r.id
|
|
876
|
+
WHERE r.id = ${input.runId}::uuid
|
|
877
|
+
AND r.state = 'needs_attention'
|
|
878
|
+
AND s.step_key = ${input.stepKey}
|
|
879
|
+
AND s.state = 'needs_attention'
|
|
880
|
+
AND s.effect_state IN ('executing', 'ambiguous')
|
|
881
|
+
FOR UPDATE OF r, s
|
|
882
|
+
`;
|
|
883
|
+
if (!row) throw new Error("workflow run is not awaiting resolution for that step");
|
|
884
|
+
|
|
885
|
+
if (input.resolution.state === "succeeded") {
|
|
886
|
+
const output = input.resolution.output ?? null;
|
|
887
|
+
await tx`
|
|
888
|
+
UPDATE workflows.step_outcome
|
|
889
|
+
SET state = 'completed',
|
|
890
|
+
outcome = ${{
|
|
891
|
+
mode: "execute",
|
|
892
|
+
outcome: { state: "completed", output },
|
|
893
|
+
}},
|
|
894
|
+
dependency = NULL,
|
|
895
|
+
effect_state = 'succeeded',
|
|
896
|
+
effect_output = ${output},
|
|
897
|
+
finished_at = now(),
|
|
898
|
+
updated_at = now()
|
|
899
|
+
WHERE run_id = ${input.runId}::uuid AND step_key = ${input.stepKey}
|
|
900
|
+
`;
|
|
901
|
+
// Older alpha workers journaled propagated control wrappers as attention
|
|
902
|
+
// outcomes. They are incomplete traversal state, not effects, so let the
|
|
903
|
+
// resumed run recompute only those wrappers.
|
|
904
|
+
await tx`
|
|
905
|
+
DELETE FROM workflows.step_outcome
|
|
906
|
+
WHERE run_id = ${input.runId}::uuid
|
|
907
|
+
AND step_key <> ${input.stepKey}
|
|
908
|
+
AND state = 'needs_attention'
|
|
909
|
+
AND effect_key IS NULL
|
|
910
|
+
`;
|
|
911
|
+
await tx`
|
|
912
|
+
UPDATE workflows.run
|
|
913
|
+
SET state = 'queued',
|
|
914
|
+
error = NULL,
|
|
915
|
+
result = NULL,
|
|
916
|
+
result_message = NULL,
|
|
917
|
+
retry_after = NULL,
|
|
918
|
+
consecutive_failures = 0,
|
|
919
|
+
finished_at = NULL,
|
|
920
|
+
updated_at = now()
|
|
921
|
+
WHERE id = ${input.runId}::uuid
|
|
922
|
+
`;
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const error = {
|
|
927
|
+
code: input.resolution.code ?? "WORKFLOW_EFFECT_CONFIRMED_FAILED",
|
|
928
|
+
message: input.resolution.message,
|
|
929
|
+
retryable: false,
|
|
930
|
+
};
|
|
931
|
+
await tx`
|
|
932
|
+
UPDATE workflows.step_outcome
|
|
933
|
+
SET state = 'failed',
|
|
934
|
+
outcome = ${{ mode: "execute", outcome: { state: "failed", error } }},
|
|
935
|
+
dependency = NULL,
|
|
936
|
+
effect_state = 'failed',
|
|
937
|
+
finished_at = now(),
|
|
938
|
+
updated_at = now()
|
|
939
|
+
WHERE run_id = ${input.runId}::uuid AND step_key = ${input.stepKey}
|
|
940
|
+
`;
|
|
941
|
+
await tx`
|
|
942
|
+
UPDATE workflows.run
|
|
943
|
+
SET state = 'failed', error = ${error}, finished_at = now(), updated_at = now()
|
|
944
|
+
WHERE id = ${input.runId}::uuid
|
|
945
|
+
`;
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
/** What an earlier attempt recorded about this step's effect, if anything. */
|
|
950
|
+
export const readWorkflowEffect = async (
|
|
951
|
+
step: { runId: string; key: string },
|
|
952
|
+
options: { db?: SQL } = {},
|
|
953
|
+
): Promise<{ key: string; state: string; output: WorkflowJsonValue } | null> => {
|
|
954
|
+
const db = options.db ?? sql;
|
|
955
|
+
const [row] = await db<{ effect_key: string | null; effect_state: string | null; effect_output: WorkflowJsonValue }[]>`
|
|
956
|
+
SELECT effect_key, effect_state, effect_output FROM workflows.step_outcome
|
|
957
|
+
WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key}
|
|
958
|
+
`;
|
|
959
|
+
return row?.effect_key && row.effect_state ? { key: row.effect_key, state: row.effect_state, output: row.effect_output } : null;
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Whether a replay may re-run this step's effect.
|
|
964
|
+
*
|
|
965
|
+
* An effect left `executing` or `ambiguous` by an earlier attempt escaped
|
|
966
|
+
* without telling us whether it landed. Repeating it is how a workflow sends
|
|
967
|
+
* the same message twice, so the answer is no and a human decides.
|
|
968
|
+
*/
|
|
969
|
+
export const isWorkflowEffectReplayable = async (step: { runId: string; key: string }, options: { db?: SQL } = {}): Promise<boolean> => {
|
|
970
|
+
const db = options.db ?? sql;
|
|
971
|
+
const [row] = await db<{ effect_state: string | null }[]>`
|
|
972
|
+
SELECT effect_state FROM workflows.step_outcome WHERE run_id = ${step.runId}::uuid AND step_key = ${step.key}
|
|
973
|
+
`;
|
|
974
|
+
return row?.effect_state !== "executing" && row?.effect_state !== "ambiguous";
|
|
975
|
+
};
|