@gadmin2n/schematics 0.0.144 → 0.0.145
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/dist/lib/application/files/gadmin2-game-angle-demo/config/.types.d.ts +10 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/approval.prisma +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +140 -19
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/data-mngt.seed.ts +0 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +694 -327
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-outbox.ts +57 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +35 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflows.ts +224 -59
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-executor.spec.ts +23 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-executor.ts +16 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.controller.spec.ts +98 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.controller.ts +26 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.dto.ts +17 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-runtime.controller.spec.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-runtime.controller.ts +3 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-runtime.dto.ts +0 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-signal-outbox.service.spec.ts +30 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-signal-outbox.service.ts +28 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction.service.spec.ts +63 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction.service.ts +151 -38
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-wait.handler.ts +4 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/human-approval.handler.spec.ts +16 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/human-approval.handler.ts +23 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.controller.spec.ts +130 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.controller.ts +62 -62
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.dto.ts +15 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.util.spec.ts +70 -9
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.util.ts +31 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-signal-outbox.service.spec.ts +30 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-signal-outbox.service.ts +28 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.spec.ts +50 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.ts +26 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.dto.ts +53 -47
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.errors.ts +0 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.service.spec.ts +541 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.service.ts +442 -200
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/outbox-retry.util.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.controller.spec.ts +150 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.controller.ts +56 -40
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.dto.ts +16 -60
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.controller.ts +13 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.spec.ts +44 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.ts +6 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.spec.ts +258 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.ts +127 -14
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/manual-workflow-execution.spec.ts +85 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/manual-workflow-execution.ts +108 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/parameter-binding.ts +5 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.spec.ts +96 -25
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +77 -34
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-callable.spec.ts +23 -34
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-callable.ts +0 -21
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +245 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +639 -188
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +408 -235
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.types.ts +1 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-data-edge.util.spec.ts +279 -95
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-data-edge.util.ts +360 -80
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +323 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +773 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution.dto.ts +7 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.dto.ts +0 -16
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.service.spec.ts +184 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.service.ts +69 -138
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-handle-validator.spec.ts +113 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-handle-validator.ts +59 -13
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-runtime.facade.ts +86 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-switch-config.spec.ts +25 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-switch-config.ts +45 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +78 -43
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +103 -40
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +57 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +1000 -240
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +2184 -317
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +279 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.ts +5 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.spec.ts +4 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.ts +5 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/widget-catalog.service.spec.ts +12 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/widget-catalog.service.ts +10 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.service.spec.ts +2 -24
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.service.ts +3 -70
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/RowActions.tsx +25 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/WorkflowActionButton.test.tsx +71 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/WorkflowActionButton.tsx +89 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/index.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/header.tsx +52 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +12 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/constants/layout.ts +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/AddDataModal.tsx +10 -33
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/AddPageModal.tsx +13 -37
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/DevShell.tsx +55 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/style.css +14 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/http.ts +10 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/list.tsx +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/workflowAction.ts +19 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/index.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/types.ts +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +1 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useReferenceUpload.ts +95 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +672 -110
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +2 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +676 -114
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +13 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +38 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +276 -376
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/CanvasAiModal.tsx +126 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/DecisionModal.test.tsx +50 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/DecisionModal.tsx +48 -11
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/inbox.tsx +32 -34
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.test.tsx +111 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.tsx +167 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/business-events.css +99 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.test.tsx +106 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +324 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.test.tsx +73 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +359 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CanvasZoomControls.tsx +46 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CreateNodeInstanceDrawer.tsx +9 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CustomNode.test.tsx +105 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CustomNode.tsx +90 -48
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DeletableEdge.tsx +3 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.test.tsx +259 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.tsx +167 -103
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +142 -140
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.test.tsx +60 -15
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.tsx +25 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/FlowRenderer.tsx +364 -200
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NewNodeDefaults.test.tsx +220 -52
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeOutputSummary.test.tsx +260 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeOutputSummary.tsx +300 -130
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +507 -33
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.tsx +630 -592
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ParameterBindingEditor.tsx +67 -70
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +90 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.tsx +325 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/RunWorkflowModal.test.tsx +52 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/RunWorkflowModal.tsx +14 -9
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/StatusCards.tsx +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/VersionPanel.tsx +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.test.tsx +42 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.tsx +76 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowWorkspaceHeader.tsx +56 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/parameter-binding-editor.css +46 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/workflow-code-editor.css +34 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/ConnectionModal.test.tsx +53 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/ConnectionModal.tsx +123 -62
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connections.css +85 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connectionsApi.ts +29 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.test.tsx +75 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +69 -36
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.test.tsx +494 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +665 -194
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useReferencedNodeInstanceConfigs.test.tsx +92 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useReferencedNodeInstanceConfigs.ts +96 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useWorkflowAgent.test.tsx +297 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useWorkflowAgent.ts +135 -65
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +53 -42
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +344 -155
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instances.tsx +79 -58
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/CreateNodeInstanceModal.tsx +123 -144
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/NodeInstanceForm.tsx +169 -98
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/node-instance-form.css +91 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/index.tsx +54 -31
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-config.css +854 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/SchemaField.tsx +41 -14
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/SchemaForm.test.tsx +75 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/SchemaForm.tsx +3 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +20 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/widget-registry.tsx +2 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +61 -66
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApprovalRuleEditor.test.tsx +33 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApprovalRuleEditor.tsx +47 -15
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/BranchConfigEditors.test.tsx +104 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CallableInputParametersEditor.tsx +22 -52
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CodeConfigEditor.test.tsx +72 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CodeConfigEditor.tsx +78 -70
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CronTriggerConfigEditor.tsx +569 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +130 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DelayConfigEditor.tsx +100 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +620 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.test.tsx +22 -45
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.tsx +25 -92
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalWaitConfigEditor.tsx +3 -11
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ForEachConfigEditor.test.tsx +58 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ForEachConfigEditor.tsx +107 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HttpRequestConfigEditor.test.tsx +212 -31
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HttpRequestConfigEditor.tsx +214 -304
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.test.tsx +175 -40
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.tsx +693 -293
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/IfElseConfigEditor.tsx +190 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ManualTriggerConfigEditor.test.tsx +152 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ManualTriggerConfigEditor.tsx +286 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ParallelConfigEditor.tsx +101 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ParameterBindingListEditor.tsx +99 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +495 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubWorkflowConfigEditor.test.tsx +124 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubWorkflowConfigEditor.tsx +222 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubjectPicker.tsx +2 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SwitchConfigEditor.tsx +204 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/UserGroupCreateModal.tsx +3 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/UserGroupEditModal.tsx +9 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/callable-workflow.ts +4 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/code-config.css +96 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/event-catalog.api.ts +117 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/http-request-config.css +242 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/human-approval-config.css +194 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/show.tsx +264 -167
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +6 -9
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types/parameter-binding.ts +19 -16
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +28 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/useWorkflowFormValidation.test.tsx +59 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/useWorkflowFormValidation.ts +24 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.test.ts +311 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +198 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/mainFlowConnection.test.ts +143 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/mainFlowConnection.ts +19 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +103 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/removeWorkflowEdges.test.ts +158 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/removeWorkflowEdges.ts +117 -9
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveEffectiveOutput.test.ts +70 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveEffectiveOutput.ts +48 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveLookupPorts.test.ts +55 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveLookupPorts.ts +95 -22
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveOutputs.test.ts +148 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveOutputs.ts +62 -11
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveWorkflowDisplay.test.ts +19 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveWorkflowDisplay.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/subWorkflowChange.test.ts +45 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/subWorkflowChange.ts +26 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/validateWorkflowDsl.test.ts +502 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/validateWorkflowDsl.ts +694 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowDateTime.test.ts +27 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowDateTime.ts +33 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowHistory.test.ts +34 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowHistory.ts +52 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowPublishError.test.ts +99 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowPublishError.ts +91 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowSkillPrompt.test.ts +74 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowSkillPrompt.ts +120 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/workflow-ui.css +481 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/styles/index.css +1 -1
- package/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/ui/WorkflowEventOutbox.ts +0 -62
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/migrate-human-approval-configs.ts +0 -86
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-event-trigger.ts +0 -60
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.external-decision.spec.ts +0 -183
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/legacy-approval-config-migration.ts +0 -227
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.spec.ts +0 -82
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.ts +0 -111
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.controller.spec.ts +0 -54
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.controller.ts +0 -147
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.module.ts +0 -10
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.service.spec.ts +0 -350
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.service.ts +0 -110
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/audit/index.ts +0 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/InstanceRequiredDialog.tsx +0 -106
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/LookupConnectionDialog.test.tsx +0 -45
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/LookupConnectionDialog.tsx +0 -43
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeTypeBadge.tsx +0 -36
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useNodeInstanceLibrary.ts +0 -41
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApproverSourceEditor.test.tsx +0 -47
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApproverSourceEditor.tsx +0 -182
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.test.tsx +0 -34
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.tsx +0 -16
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/approver-source.test.ts +0 -25
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/approver-source.ts +0 -42
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/lookupConnection.test.ts +0 -208
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/lookupConnection.ts +0 -246
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/create.tsx +0 -111
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/edit.tsx +0 -127
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/index.tsx +0 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/list.tsx +0 -282
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/show.tsx +0 -74
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BadRequestException,
|
|
3
|
+
ConflictException,
|
|
3
4
|
Injectable,
|
|
4
5
|
Logger,
|
|
5
6
|
NotFoundException,
|
|
6
7
|
UnauthorizedException,
|
|
7
8
|
} from '@nestjs/common';
|
|
9
|
+
import { Interval } from '@nestjs/schedule';
|
|
8
10
|
import { Prisma } from '@prisma/client';
|
|
11
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
9
12
|
import { PrismaService } from 'nestjs-prisma';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
10
14
|
import { HumanApprovalService } from '../humanApproval/human-approval.service';
|
|
11
15
|
import { validateDslGraph } from './dsl-validate.util';
|
|
12
|
-
import { TemporalService } from './temporal.service';
|
|
13
|
-
import { verifyWebhookSignature } from './webhook-signature.util';
|
|
14
16
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
assertManualWorkflowExecution,
|
|
18
|
+
normalizeIdempotencyKey,
|
|
19
|
+
scopedIdempotencyKey,
|
|
20
|
+
workflowDataHash,
|
|
21
|
+
} from './manual-workflow-execution';
|
|
22
|
+
import {
|
|
23
|
+
TemporalService,
|
|
24
|
+
WorkflowRetryCheckpoint,
|
|
25
|
+
workflowTemporalId,
|
|
26
|
+
} from './temporal.service';
|
|
27
|
+
import { verifyWebhookSignature } from './webhook-signature.util';
|
|
28
|
+
import { describeCallableWorkflow } from './workflow-callable';
|
|
18
29
|
import { WorkflowCompilerService } from './workflow-compiler.service';
|
|
19
30
|
import { DraftWorkflowDsl } from './workflow-compiler.types';
|
|
20
31
|
import { validateWorkflowDataEdges } from './workflow-data-edge.util';
|
|
@@ -24,6 +35,322 @@ import {
|
|
|
24
35
|
validateWorkflowHandles,
|
|
25
36
|
} from './workflow-handle-validator';
|
|
26
37
|
|
|
38
|
+
export interface EventCatalogDeliveryRow {
|
|
39
|
+
registrationCode: string;
|
|
40
|
+
createdAt: Date;
|
|
41
|
+
status: string;
|
|
42
|
+
processedAt: Date | null;
|
|
43
|
+
retryCount: number;
|
|
44
|
+
lastError: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BusinessEventInstanceRow {
|
|
48
|
+
id: bigint;
|
|
49
|
+
workflowId: bigint;
|
|
50
|
+
workflowName: string;
|
|
51
|
+
version: number;
|
|
52
|
+
status: string;
|
|
53
|
+
retryOfInstanceId: bigint | null;
|
|
54
|
+
startedAt: Date | null;
|
|
55
|
+
finishedAt: Date | null;
|
|
56
|
+
createdAt: Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BusinessEventCatalogItem {
|
|
60
|
+
registrationCode: string;
|
|
61
|
+
label: string;
|
|
62
|
+
schemaName: string;
|
|
63
|
+
tableName: string;
|
|
64
|
+
operation: 'INSERT' | 'UPDATE' | 'DELETE';
|
|
65
|
+
watchedFields: string[];
|
|
66
|
+
primaryKeyFields: string[];
|
|
67
|
+
isEnabled: boolean;
|
|
68
|
+
fields: Array<{
|
|
69
|
+
path: string;
|
|
70
|
+
label: string;
|
|
71
|
+
description?: string;
|
|
72
|
+
type: string;
|
|
73
|
+
required: boolean;
|
|
74
|
+
primaryKey: boolean;
|
|
75
|
+
nullable: boolean;
|
|
76
|
+
}>;
|
|
77
|
+
defaultKeyPath?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface BusinessEventColumnRow {
|
|
81
|
+
schemaName: string;
|
|
82
|
+
tableName: string;
|
|
83
|
+
columnName: string;
|
|
84
|
+
dataType: string;
|
|
85
|
+
nullable: boolean;
|
|
86
|
+
primaryKey: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type CronSchedulePlan =
|
|
90
|
+
| { kind: 'NONE'; workflowId: bigint }
|
|
91
|
+
| { kind: 'DELETE'; workflowId: bigint }
|
|
92
|
+
| {
|
|
93
|
+
kind: 'UPSERT';
|
|
94
|
+
workflowId: bigint;
|
|
95
|
+
isEnabled: boolean;
|
|
96
|
+
version: { id: bigint; dsl: unknown };
|
|
97
|
+
};
|
|
98
|
+
const WORKFLOW_PUBLISH_LOCK_KEY = BigInt(734_221_907);
|
|
99
|
+
const RETRYABLE_MAIN_FLOW_NODE_TYPES = new Set([
|
|
100
|
+
'http_request',
|
|
101
|
+
'db_execute',
|
|
102
|
+
'code',
|
|
103
|
+
'delay',
|
|
104
|
+
'sub_workflow',
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
interface WorkflowExecutionInternalOptions {
|
|
108
|
+
operationScopeId?: string;
|
|
109
|
+
retryCheckpoint?: WorkflowRetryCheckpoint;
|
|
110
|
+
retryOfInstanceId?: bigint;
|
|
111
|
+
retryFromNodeId?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dslUsesBusinessEvent(dsl: unknown, registrationCode: string): boolean {
|
|
115
|
+
const value = asRecord(dsl);
|
|
116
|
+
const nodes = Array.isArray(value.nodes) ? value.nodes : [];
|
|
117
|
+
return nodes.some((item) => {
|
|
118
|
+
const node = asRecord(item);
|
|
119
|
+
return (
|
|
120
|
+
node.type === 'event_trigger' &&
|
|
121
|
+
asRecord(node.config).registrationCode === registrationCode
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildBusinessEventCatalog(
|
|
127
|
+
rows: Array<{
|
|
128
|
+
code: string;
|
|
129
|
+
schemaName: string;
|
|
130
|
+
tableName: string;
|
|
131
|
+
operation: string;
|
|
132
|
+
watchedFields: Prisma.JsonValue;
|
|
133
|
+
primaryKeyFields: Prisma.JsonValue;
|
|
134
|
+
isEnabled: boolean;
|
|
135
|
+
}>,
|
|
136
|
+
columns: BusinessEventColumnRow[],
|
|
137
|
+
): BusinessEventCatalogItem[] {
|
|
138
|
+
return rows.map((row) => {
|
|
139
|
+
const watchedFields = stringArray(row.watchedFields);
|
|
140
|
+
const primaryKeyFields = stringArray(row.primaryKeyFields);
|
|
141
|
+
const sourceColumns = columns.filter(
|
|
142
|
+
(column) =>
|
|
143
|
+
column.schemaName === row.schemaName &&
|
|
144
|
+
column.tableName === row.tableName,
|
|
145
|
+
);
|
|
146
|
+
const prefixes =
|
|
147
|
+
row.operation === 'INSERT'
|
|
148
|
+
? ['after']
|
|
149
|
+
: row.operation === 'DELETE'
|
|
150
|
+
? ['before']
|
|
151
|
+
: ['before', 'after'];
|
|
152
|
+
const fields = [
|
|
153
|
+
...primaryKeyFields.flatMap((columnName) => {
|
|
154
|
+
const column = sourceColumns.find(
|
|
155
|
+
(candidate) => candidate.columnName === columnName,
|
|
156
|
+
);
|
|
157
|
+
return column
|
|
158
|
+
? [eventField(`primaryKey.${columnName}`, column, true, false)]
|
|
159
|
+
: [];
|
|
160
|
+
}),
|
|
161
|
+
...prefixes.flatMap((prefix) =>
|
|
162
|
+
sourceColumns.map((column) =>
|
|
163
|
+
eventField(
|
|
164
|
+
`${prefix}.${column.columnName}`,
|
|
165
|
+
column,
|
|
166
|
+
false,
|
|
167
|
+
column.nullable,
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
];
|
|
172
|
+
return {
|
|
173
|
+
registrationCode: row.code,
|
|
174
|
+
label: `${row.schemaName}.${row.tableName} · ${row.operation}`,
|
|
175
|
+
schemaName: row.schemaName,
|
|
176
|
+
tableName: row.tableName,
|
|
177
|
+
operation: row.operation as BusinessEventCatalogItem['operation'],
|
|
178
|
+
watchedFields,
|
|
179
|
+
primaryKeyFields,
|
|
180
|
+
isEnabled: row.isEnabled,
|
|
181
|
+
fields,
|
|
182
|
+
...(primaryKeyFields[0]
|
|
183
|
+
? { defaultKeyPath: `primaryKey.${primaryKeyFields[0]}` }
|
|
184
|
+
: {}),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function unknownBusinessEventNames(
|
|
190
|
+
dsl: unknown,
|
|
191
|
+
catalog: Array<{ registrationCode: string }>,
|
|
192
|
+
): string[] {
|
|
193
|
+
const value = asRecord(dsl);
|
|
194
|
+
const nodes = Array.isArray(value.nodes) ? value.nodes : [];
|
|
195
|
+
const available = new Set(catalog.map((item) => item.registrationCode));
|
|
196
|
+
return [
|
|
197
|
+
...new Set(
|
|
198
|
+
nodes.flatMap((item) => {
|
|
199
|
+
const node = asRecord(item);
|
|
200
|
+
if (node.type !== 'event_trigger') return [];
|
|
201
|
+
const registrationCode = asRecord(node.config).registrationCode;
|
|
202
|
+
return typeof registrationCode === 'string' &&
|
|
203
|
+
!available.has(registrationCode)
|
|
204
|
+
? [registrationCode]
|
|
205
|
+
: [];
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function validateBusinessEventTriggers(
|
|
212
|
+
dsl: unknown,
|
|
213
|
+
catalog: BusinessEventCatalogItem[],
|
|
214
|
+
): void {
|
|
215
|
+
const value = asRecord(dsl);
|
|
216
|
+
const nodes = Array.isArray(value.nodes) ? value.nodes : [];
|
|
217
|
+
const eventByCode = new Map(
|
|
218
|
+
catalog.map((event) => [event.registrationCode, event] as const),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
for (const item of nodes) {
|
|
222
|
+
const node = asRecord(item);
|
|
223
|
+
if (node.type !== 'event_trigger') continue;
|
|
224
|
+
const config = asRecord(node.config);
|
|
225
|
+
const registrationCode = String(config.registrationCode ?? '');
|
|
226
|
+
const event = eventByCode.get(registrationCode);
|
|
227
|
+
if (!event) continue;
|
|
228
|
+
|
|
229
|
+
validateBusinessEventMatch(config.match, event);
|
|
230
|
+
|
|
231
|
+
if (config.processingMode !== 'SERIAL_BY_KEY') continue;
|
|
232
|
+
|
|
233
|
+
const keyPath = String(config.concurrencyKeyPath ?? '');
|
|
234
|
+
const field = event.fields.find((candidate) => candidate.path === keyPath);
|
|
235
|
+
if (
|
|
236
|
+
!field ||
|
|
237
|
+
field.nullable ||
|
|
238
|
+
!['string', 'number', 'boolean'].includes(field.type)
|
|
239
|
+
)
|
|
240
|
+
throw new BadRequestException({
|
|
241
|
+
code: 'WORKFLOW_EVENT_KEY_INVALID',
|
|
242
|
+
message: `业务事件“${event.label}”的顺序处理字段必须存在、不能为空,并且是文本、数字或是/否`,
|
|
243
|
+
registrationCode,
|
|
244
|
+
concurrencyKeyPath: keyPath,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const EVENT_MATCH_OPERATORS = new Set(['EQ', 'NEQ', 'EMPTY', 'NOT_EMPTY']);
|
|
250
|
+
const EVENT_MATCH_UNARY_OPERATORS = new Set(['EMPTY', 'NOT_EMPTY']);
|
|
251
|
+
|
|
252
|
+
function validateBusinessEventMatch(
|
|
253
|
+
value: unknown,
|
|
254
|
+
event: BusinessEventCatalogItem,
|
|
255
|
+
): void {
|
|
256
|
+
if (value === undefined) return;
|
|
257
|
+
const match = asRecord(value);
|
|
258
|
+
const conditions = Array.isArray(match.conditions) ? match.conditions : [];
|
|
259
|
+
if (
|
|
260
|
+
!['AND', 'OR'].includes(String(match.combineOperation)) ||
|
|
261
|
+
conditions.length === 0
|
|
262
|
+
) {
|
|
263
|
+
throw invalidBusinessEventMatch(event, '启动条件至少需要一条有效条件');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const item of conditions) {
|
|
267
|
+
const condition = asRecord(item);
|
|
268
|
+
const left = asRecord(condition.left);
|
|
269
|
+
const path = String(left.path ?? '');
|
|
270
|
+
const field = event.fields.find((candidate) => candidate.path === path);
|
|
271
|
+
if (
|
|
272
|
+
left.source !== 'WORKFLOW' ||
|
|
273
|
+
!field ||
|
|
274
|
+
!['string', 'number', 'boolean'].includes(field.type)
|
|
275
|
+
) {
|
|
276
|
+
throw invalidBusinessEventMatch(
|
|
277
|
+
event,
|
|
278
|
+
`字段“${path || '未选择'}”不存在或不支持用于启动条件`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const operator = String(condition.operator ?? '');
|
|
283
|
+
if (!EVENT_MATCH_OPERATORS.has(operator)) {
|
|
284
|
+
throw invalidBusinessEventMatch(event, '启动条件的判断方式无效');
|
|
285
|
+
}
|
|
286
|
+
if (EVENT_MATCH_UNARY_OPERATORS.has(operator)) continue;
|
|
287
|
+
|
|
288
|
+
const right = asRecord(condition.right);
|
|
289
|
+
const expectedValueType =
|
|
290
|
+
field.type === 'number'
|
|
291
|
+
? 'NUMBER'
|
|
292
|
+
: field.type === 'boolean'
|
|
293
|
+
? 'BOOLEAN'
|
|
294
|
+
: 'STRING';
|
|
295
|
+
if (
|
|
296
|
+
right.source !== 'FIXED' ||
|
|
297
|
+
right.valueType !== expectedValueType ||
|
|
298
|
+
typeof right.value !== 'string' ||
|
|
299
|
+
(expectedValueType === 'NUMBER' &&
|
|
300
|
+
(!right.value.trim() || !Number.isFinite(Number(right.value)))) ||
|
|
301
|
+
(expectedValueType === 'BOOLEAN' &&
|
|
302
|
+
!['true', 'false'].includes(right.value))
|
|
303
|
+
) {
|
|
304
|
+
throw invalidBusinessEventMatch(event, `字段“${path}”的比较值类型不正确`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function invalidBusinessEventMatch(
|
|
310
|
+
event: BusinessEventCatalogItem,
|
|
311
|
+
message: string,
|
|
312
|
+
): BadRequestException {
|
|
313
|
+
return new BadRequestException({
|
|
314
|
+
code: 'WORKFLOW_EVENT_MATCH_INVALID',
|
|
315
|
+
message: `业务事件“${event.label}”的${message}`,
|
|
316
|
+
registrationCode: event.registrationCode,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function stringArray(value: Prisma.JsonValue): string[] {
|
|
321
|
+
return Array.isArray(value)
|
|
322
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
323
|
+
: [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function eventField(
|
|
327
|
+
path: string,
|
|
328
|
+
column: BusinessEventColumnRow,
|
|
329
|
+
primaryKey: boolean,
|
|
330
|
+
nullable: boolean,
|
|
331
|
+
) {
|
|
332
|
+
return {
|
|
333
|
+
path,
|
|
334
|
+
label: path,
|
|
335
|
+
type: postgresTypeToFieldType(column.dataType),
|
|
336
|
+
required: !nullable,
|
|
337
|
+
primaryKey,
|
|
338
|
+
nullable,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function postgresTypeToFieldType(dataType: string): string {
|
|
343
|
+
if (
|
|
344
|
+
/^(smallint|integer|bigint|numeric|decimal|real|double precision)/.test(
|
|
345
|
+
dataType,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
return 'number';
|
|
349
|
+
if (dataType === 'boolean') return 'boolean';
|
|
350
|
+
if (dataType === 'json' || dataType === 'jsonb') return 'object';
|
|
351
|
+
return 'string';
|
|
352
|
+
}
|
|
353
|
+
|
|
27
354
|
@Injectable()
|
|
28
355
|
export class WorkflowService {
|
|
29
356
|
private readonly logger = new Logger(WorkflowService.name);
|
|
@@ -35,33 +362,161 @@ export class WorkflowService {
|
|
|
35
362
|
private workflowCompiler: WorkflowCompilerService,
|
|
36
363
|
) {}
|
|
37
364
|
|
|
38
|
-
private validateCallableDraft(dsl: any): void {
|
|
39
|
-
if (
|
|
40
|
-
!Array.isArray(dsl?.nodes) ||
|
|
41
|
-
!dsl.nodes.some((node: any) => node?.type === 'callable_trigger')
|
|
42
|
-
)
|
|
43
|
-
return;
|
|
44
|
-
try {
|
|
45
|
-
validateCallableWorkflow(dsl);
|
|
46
|
-
} catch (error) {
|
|
47
|
-
throw new BadRequestException({
|
|
48
|
-
code: 'CALLABLE_WORKFLOW_INVALID',
|
|
49
|
-
message:
|
|
50
|
-
error instanceof Error ? error.message : '可调用 Workflow 配置无效',
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
365
|
/**
|
|
56
366
|
* Find the cron_trigger node config in a DSL, if any.
|
|
57
|
-
* Returns null when no cron_trigger node or when its
|
|
367
|
+
* Returns null when no cron_trigger node or when its schedule is missing.
|
|
58
368
|
*/
|
|
59
369
|
private extractCronConfig(
|
|
60
370
|
dsl: any,
|
|
61
|
-
): {
|
|
371
|
+
): { cronExpressions: string[]; timezone?: string } | null {
|
|
62
372
|
const node = dsl?.nodes?.find((n: any) => n?.type === 'cron_trigger');
|
|
63
|
-
if (!node?.config?.
|
|
64
|
-
return {
|
|
373
|
+
if (!node?.config?.cronExpressions?.length) return null;
|
|
374
|
+
return {
|
|
375
|
+
cronExpressions: node.config.cronExpressions,
|
|
376
|
+
timezone: node.config.timezone,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private validateCronConfig(config: {
|
|
381
|
+
cronExpressions: string[];
|
|
382
|
+
timezone?: string;
|
|
383
|
+
}): void {
|
|
384
|
+
try {
|
|
385
|
+
if (config.cronExpressions.length === 0) throw new Error();
|
|
386
|
+
for (const cronExpression of config.cronExpressions) {
|
|
387
|
+
CronExpressionParser.parse(cronExpression, {
|
|
388
|
+
...(config.timezone && { tz: config.timezone }),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
throw new BadRequestException({
|
|
393
|
+
code: 'WORKFLOW_CRON_INVALID',
|
|
394
|
+
message: '定时触发的 Cron 表达式或时区无效',
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async syncCronSchedule(
|
|
400
|
+
workflowId: bigint,
|
|
401
|
+
version: { id: bigint; dsl: unknown },
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
const cronConfig = this.extractCronConfig(version.dsl);
|
|
404
|
+
if (cronConfig) {
|
|
405
|
+
this.validateCronConfig(cronConfig);
|
|
406
|
+
await this.temporalService.upsertCronSchedule({
|
|
407
|
+
workflowId: Number(workflowId),
|
|
408
|
+
versionId: Number(version.id),
|
|
409
|
+
dsl: version.dsl,
|
|
410
|
+
cronExpressions: cronConfig.cronExpressions,
|
|
411
|
+
timezone: cronConfig.timezone,
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await this.temporalService.deleteCronSchedule(Number(workflowId));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private hasCronHistory(versions: Array<{ dsl: unknown }> | undefined) {
|
|
419
|
+
return Boolean(
|
|
420
|
+
versions?.some((version) => this.extractCronConfig(version.dsl)),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async readCronSchedulePlanLocked(
|
|
425
|
+
tx: Prisma.TransactionClient,
|
|
426
|
+
workflowId: bigint,
|
|
427
|
+
): Promise<{ active: boolean; plan: CronSchedulePlan }> {
|
|
428
|
+
const rows = await tx.$queryRaw<
|
|
429
|
+
Array<{ isEnabled: boolean; status: string }>
|
|
430
|
+
>`SELECT is_enabled AS "isEnabled", status::text AS status
|
|
431
|
+
FROM t_workflow
|
|
432
|
+
WHERE id = ${workflowId}
|
|
433
|
+
FOR UPDATE`;
|
|
434
|
+
const workflow = rows[0];
|
|
435
|
+
if (!workflow || workflow.status === 'DELETING')
|
|
436
|
+
return { active: false, plan: { kind: 'NONE', workflowId } };
|
|
437
|
+
|
|
438
|
+
const versions = await tx.workflowVersion.findMany({
|
|
439
|
+
where: { workflowId },
|
|
440
|
+
orderBy: { version: 'desc' },
|
|
441
|
+
select: { id: true, dsl: true },
|
|
442
|
+
});
|
|
443
|
+
const latestVersion = versions[0];
|
|
444
|
+
if (!latestVersion || !this.hasCronHistory(versions))
|
|
445
|
+
return { active: true, plan: { kind: 'NONE', workflowId } };
|
|
446
|
+
|
|
447
|
+
const cronConfig = this.extractCronConfig(latestVersion.dsl);
|
|
448
|
+
if (!cronConfig)
|
|
449
|
+
return { active: true, plan: { kind: 'DELETE', workflowId } };
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
active: true,
|
|
453
|
+
plan: {
|
|
454
|
+
kind: 'UPSERT',
|
|
455
|
+
workflowId,
|
|
456
|
+
isEnabled: workflow.isEnabled,
|
|
457
|
+
version: latestVersion,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async applyCronSchedulePlan(plan: CronSchedulePlan): Promise<void> {
|
|
463
|
+
if (plan.kind === 'NONE') return;
|
|
464
|
+
if (plan.kind === 'DELETE') {
|
|
465
|
+
await this.temporalService.deleteCronSchedule(Number(plan.workflowId));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
await this.syncCronSchedule(plan.workflowId, plan.version);
|
|
470
|
+
if (plan.isEnabled)
|
|
471
|
+
await this.temporalService.unpauseCronSchedule(Number(plan.workflowId));
|
|
472
|
+
else await this.temporalService.pauseCronSchedule(Number(plan.workflowId));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private cronSchedulePlanKey(plan: CronSchedulePlan): string {
|
|
476
|
+
if (plan.kind !== 'UPSERT') return plan.kind;
|
|
477
|
+
return `${plan.kind}:${plan.version.id}:${plan.isEnabled}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async reconcileCronScheduleCurrent(
|
|
481
|
+
workflowId: bigint,
|
|
482
|
+
): Promise<boolean> {
|
|
483
|
+
let snapshot = await this.prisma.$transaction((tx) =>
|
|
484
|
+
this.readCronSchedulePlanLocked(tx, workflowId),
|
|
485
|
+
);
|
|
486
|
+
if (!snapshot.active) return false;
|
|
487
|
+
|
|
488
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
489
|
+
await this.applyCronSchedulePlan(snapshot.plan);
|
|
490
|
+
const latest = await this.prisma.$transaction((tx) =>
|
|
491
|
+
this.readCronSchedulePlanLocked(tx, workflowId),
|
|
492
|
+
);
|
|
493
|
+
if (!latest.active) return false;
|
|
494
|
+
if (
|
|
495
|
+
this.cronSchedulePlanKey(latest.plan) ===
|
|
496
|
+
this.cronSchedulePlanKey(snapshot.plan)
|
|
497
|
+
)
|
|
498
|
+
return true;
|
|
499
|
+
snapshot = latest;
|
|
500
|
+
}
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private workflowDeletingError() {
|
|
505
|
+
return new ConflictException({
|
|
506
|
+
code: 'WORKFLOW_DELETING',
|
|
507
|
+
message: 'Workflow 正在删除,不能再修改或执行',
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private hasUnpublishedChanges(workflow: {
|
|
512
|
+
dsl: unknown;
|
|
513
|
+
versions?: Array<{ dsl?: unknown }>;
|
|
514
|
+
}): boolean {
|
|
515
|
+
const latest = workflow.versions?.[0]?.dsl;
|
|
516
|
+
return Boolean(
|
|
517
|
+
workflow.dsl &&
|
|
518
|
+
(!latest || !areWorkflowDslSourcesEqual(workflow.dsl, latest)),
|
|
519
|
+
);
|
|
65
520
|
}
|
|
66
521
|
|
|
67
522
|
/**
|
|
@@ -73,8 +528,6 @@ export class WorkflowService {
|
|
|
73
528
|
method: string;
|
|
74
529
|
secret?: string;
|
|
75
530
|
verificationMode: 'HMAC' | 'NONE';
|
|
76
|
-
responseMode: 'IMMEDIATE' | 'WAIT_FOR_RESULT';
|
|
77
|
-
responseTimeoutSeconds: number;
|
|
78
531
|
}> {
|
|
79
532
|
if (!Array.isArray(dsl?.nodes)) return [];
|
|
80
533
|
return dsl.nodes
|
|
@@ -88,16 +541,6 @@ export class WorkflowService {
|
|
|
88
541
|
secret: n.config.secret,
|
|
89
542
|
verificationMode:
|
|
90
543
|
n.config.verificationMode === 'NONE' ? 'NONE' : 'HMAC',
|
|
91
|
-
responseMode:
|
|
92
|
-
n.config.responseMode === 'WAIT_FOR_RESULT'
|
|
93
|
-
? 'WAIT_FOR_RESULT'
|
|
94
|
-
: 'IMMEDIATE',
|
|
95
|
-
responseTimeoutSeconds:
|
|
96
|
-
Number.isInteger(n.config.responseTimeoutSeconds) &&
|
|
97
|
-
n.config.responseTimeoutSeconds >= 1 &&
|
|
98
|
-
n.config.responseTimeoutSeconds <= 300
|
|
99
|
-
? n.config.responseTimeoutSeconds
|
|
100
|
-
: 60,
|
|
101
544
|
}));
|
|
102
545
|
}
|
|
103
546
|
|
|
@@ -129,7 +572,7 @@ export class WorkflowService {
|
|
|
129
572
|
versions: {
|
|
130
573
|
orderBy: { version: 'desc' },
|
|
131
574
|
take: 1,
|
|
132
|
-
select: { version: true, changeSummary: true },
|
|
575
|
+
select: { version: true, changeSummary: true, dsl: true },
|
|
133
576
|
},
|
|
134
577
|
_count: { select: { instances: true } },
|
|
135
578
|
},
|
|
@@ -178,6 +621,7 @@ export class WorkflowService {
|
|
|
178
621
|
|
|
179
622
|
const enrichedItems = items.map((item) => ({
|
|
180
623
|
...item,
|
|
624
|
+
hasUnpublishedChanges: this.hasUnpublishedChanges(item),
|
|
181
625
|
runs: runsMap.get(item.id) || {
|
|
182
626
|
pending: 0,
|
|
183
627
|
running: 0,
|
|
@@ -192,43 +636,54 @@ export class WorkflowService {
|
|
|
192
636
|
|
|
193
637
|
async findCallableWorkflows() {
|
|
194
638
|
const workflows = await this.prisma.workflow.findMany({
|
|
195
|
-
where: {
|
|
639
|
+
where: { isEnabled: true, versions: { some: {} } },
|
|
196
640
|
orderBy: { name: 'asc' },
|
|
197
641
|
include: {
|
|
198
642
|
versions: {
|
|
199
643
|
orderBy: { version: 'desc' },
|
|
200
|
-
|
|
201
|
-
select: { dsl: true },
|
|
644
|
+
select: { id: true, version: true, dsl: true },
|
|
202
645
|
},
|
|
203
646
|
},
|
|
204
647
|
});
|
|
205
648
|
return workflows.flatMap((workflow) => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
649
|
+
const versions = workflow.versions.flatMap((version) => {
|
|
650
|
+
try {
|
|
651
|
+
const descriptor = describeCallableWorkflow(version.dsl);
|
|
652
|
+
return descriptor
|
|
653
|
+
? [
|
|
654
|
+
{
|
|
655
|
+
id: String(version.id),
|
|
656
|
+
version: version.version,
|
|
657
|
+
inputParameters: descriptor.inputParameters,
|
|
658
|
+
},
|
|
659
|
+
]
|
|
660
|
+
: [];
|
|
661
|
+
} catch {
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
if (versions.length === 0) return [];
|
|
666
|
+
return [
|
|
667
|
+
{
|
|
668
|
+
id: String(workflow.id),
|
|
669
|
+
name: workflow.name,
|
|
670
|
+
description: workflow.description,
|
|
671
|
+
latestVersionId: versions[0].id,
|
|
672
|
+
versions,
|
|
673
|
+
},
|
|
674
|
+
];
|
|
220
675
|
});
|
|
221
676
|
}
|
|
222
677
|
|
|
223
678
|
async findExternalInteractionOptions() {
|
|
224
679
|
const workflows = await this.prisma.workflow.findMany({
|
|
225
|
-
where: {
|
|
680
|
+
where: { isEnabled: true, versions: { some: {} } },
|
|
226
681
|
orderBy: { name: 'asc' },
|
|
227
682
|
include: {
|
|
228
683
|
versions: {
|
|
229
684
|
orderBy: { version: 'desc' },
|
|
230
685
|
take: 1,
|
|
231
|
-
select: { dsl: true },
|
|
686
|
+
select: { id: true, version: true, dsl: true },
|
|
232
687
|
},
|
|
233
688
|
},
|
|
234
689
|
});
|
|
@@ -254,6 +709,472 @@ export class WorkflowService {
|
|
|
254
709
|
return { dispatchWorkflows: [], callbackWorkflows };
|
|
255
710
|
}
|
|
256
711
|
|
|
712
|
+
async findBusinessEventCatalog() {
|
|
713
|
+
const [events, columns, schemaName] = await Promise.all([
|
|
714
|
+
this.prisma.workflowBusinessEventRegistration.findMany({
|
|
715
|
+
where: { isEnabled: true },
|
|
716
|
+
orderBy: [
|
|
717
|
+
{ schemaName: 'asc' },
|
|
718
|
+
{ tableName: 'asc' },
|
|
719
|
+
{ operation: 'asc' },
|
|
720
|
+
],
|
|
721
|
+
}),
|
|
722
|
+
this.findBusinessEventColumns(),
|
|
723
|
+
this.findBusinessEventSchema(),
|
|
724
|
+
]);
|
|
725
|
+
return {
|
|
726
|
+
items: buildBusinessEventCatalog(
|
|
727
|
+
events.filter((event) => event.schemaName === schemaName),
|
|
728
|
+
columns,
|
|
729
|
+
),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async findBusinessEventSources() {
|
|
734
|
+
const columns = await this.findBusinessEventColumns();
|
|
735
|
+
const sources = new Map<
|
|
736
|
+
string,
|
|
737
|
+
{
|
|
738
|
+
schemaName: string;
|
|
739
|
+
tableName: string;
|
|
740
|
+
columns: Array<{
|
|
741
|
+
name: string;
|
|
742
|
+
dataType: string;
|
|
743
|
+
nullable: boolean;
|
|
744
|
+
primaryKey: boolean;
|
|
745
|
+
}>;
|
|
746
|
+
}
|
|
747
|
+
>();
|
|
748
|
+
for (const column of columns) {
|
|
749
|
+
const key = `${column.schemaName}.${column.tableName}`;
|
|
750
|
+
const source = sources.get(key) ?? {
|
|
751
|
+
schemaName: column.schemaName,
|
|
752
|
+
tableName: column.tableName,
|
|
753
|
+
columns: [],
|
|
754
|
+
};
|
|
755
|
+
source.columns.push({
|
|
756
|
+
name: column.columnName,
|
|
757
|
+
dataType: column.dataType,
|
|
758
|
+
nullable: column.nullable,
|
|
759
|
+
primaryKey: column.primaryKey,
|
|
760
|
+
});
|
|
761
|
+
sources.set(key, source);
|
|
762
|
+
}
|
|
763
|
+
return { items: [...sources.values()] };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private async findBusinessEventColumns(): Promise<BusinessEventColumnRow[]> {
|
|
767
|
+
return this.prisma.$queryRaw<BusinessEventColumnRow[]>`
|
|
768
|
+
SELECT
|
|
769
|
+
namespace.nspname AS "schemaName",
|
|
770
|
+
relation.relname AS "tableName",
|
|
771
|
+
attribute.attname AS "columnName",
|
|
772
|
+
format_type(attribute.atttypid, attribute.atttypmod) AS "dataType",
|
|
773
|
+
NOT attribute.attnotnull AS nullable,
|
|
774
|
+
EXISTS (
|
|
775
|
+
SELECT 1 FROM pg_index primary_index
|
|
776
|
+
WHERE primary_index.indrelid = relation.oid
|
|
777
|
+
AND primary_index.indisprimary
|
|
778
|
+
AND attribute.attnum = ANY(primary_index.indkey)
|
|
779
|
+
) AS "primaryKey"
|
|
780
|
+
FROM pg_class relation
|
|
781
|
+
JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace
|
|
782
|
+
JOIN pg_attribute attribute ON attribute.attrelid = relation.oid
|
|
783
|
+
WHERE relation.relkind IN ('r', 'p')
|
|
784
|
+
AND namespace.nspname = current_schema()
|
|
785
|
+
AND attribute.attnum > 0
|
|
786
|
+
AND NOT attribute.attisdropped
|
|
787
|
+
AND relation.relname NOT LIKE 't_workflow%'
|
|
788
|
+
AND relation.relname NOT LIKE 't_external_interaction%'
|
|
789
|
+
AND relation.relname NOT LIKE 't_approval%'
|
|
790
|
+
ORDER BY namespace.nspname, relation.relname, attribute.attnum
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private async findBusinessEventSchema(): Promise<string> {
|
|
795
|
+
const rows = await this.prisma.$queryRaw<Array<{ schemaName: string }>>`
|
|
796
|
+
SELECT current_schema() AS "schemaName"
|
|
797
|
+
`;
|
|
798
|
+
return rows[0]?.schemaName || 'public';
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async registerBusinessEvent(
|
|
802
|
+
data: {
|
|
803
|
+
schemaName: string;
|
|
804
|
+
tableName: string;
|
|
805
|
+
operation: 'INSERT' | 'UPDATE' | 'DELETE';
|
|
806
|
+
watchedFields: string[];
|
|
807
|
+
},
|
|
808
|
+
creator: string,
|
|
809
|
+
) {
|
|
810
|
+
const [columns, currentSchema] = await Promise.all([
|
|
811
|
+
this.findBusinessEventColumns(),
|
|
812
|
+
this.findBusinessEventSchema(),
|
|
813
|
+
]);
|
|
814
|
+
if (data.schemaName !== currentSchema)
|
|
815
|
+
throw new BadRequestException(
|
|
816
|
+
`只能登记当前数据库 Schema(${currentSchema})中的业务表`,
|
|
817
|
+
);
|
|
818
|
+
const sourceColumns = columns.filter(
|
|
819
|
+
(column) =>
|
|
820
|
+
column.schemaName === data.schemaName &&
|
|
821
|
+
column.tableName === data.tableName,
|
|
822
|
+
);
|
|
823
|
+
if (sourceColumns.length === 0)
|
|
824
|
+
throw new BadRequestException('请选择可监听的数据库表');
|
|
825
|
+
|
|
826
|
+
const requestedFields = [
|
|
827
|
+
...new Set(
|
|
828
|
+
data.watchedFields.map((field) => field.trim()).filter(Boolean),
|
|
829
|
+
),
|
|
830
|
+
].sort();
|
|
831
|
+
if (data.operation === 'UPDATE' && requestedFields.length === 0)
|
|
832
|
+
throw new BadRequestException('请至少选择一个监听字段');
|
|
833
|
+
const watchedFields =
|
|
834
|
+
data.operation === 'UPDATE'
|
|
835
|
+
? requestedFields
|
|
836
|
+
: sourceColumns.map((column) => column.columnName).sort();
|
|
837
|
+
const availableFields = new Set(
|
|
838
|
+
sourceColumns.map((column) => column.columnName),
|
|
839
|
+
);
|
|
840
|
+
const unknownFields = watchedFields.filter(
|
|
841
|
+
(field) => !availableFields.has(field),
|
|
842
|
+
);
|
|
843
|
+
if (unknownFields.length > 0)
|
|
844
|
+
throw new BadRequestException(`字段不存在:${unknownFields.join('、')}`);
|
|
845
|
+
const primaryKeyFields = sourceColumns
|
|
846
|
+
.filter((column) => column.primaryKey)
|
|
847
|
+
.map((column) => column.columnName);
|
|
848
|
+
if (primaryKeyFields.length === 0)
|
|
849
|
+
throw new BadRequestException('被监听表必须有主键');
|
|
850
|
+
|
|
851
|
+
const registrationKey = createHash('sha256')
|
|
852
|
+
.update(
|
|
853
|
+
JSON.stringify({
|
|
854
|
+
schemaName: data.schemaName,
|
|
855
|
+
tableName: data.tableName,
|
|
856
|
+
operation: data.operation,
|
|
857
|
+
watchedFields,
|
|
858
|
+
}),
|
|
859
|
+
)
|
|
860
|
+
.digest('hex');
|
|
861
|
+
const event = await this.prisma.$transaction(async (tx) => {
|
|
862
|
+
await tx.$queryRaw`
|
|
863
|
+
SELECT pg_advisory_xact_lock(${WORKFLOW_PUBLISH_LOCK_KEY})::text AS "lock"
|
|
864
|
+
`;
|
|
865
|
+
const existing = await tx.workflowBusinessEventRegistration.findUnique({
|
|
866
|
+
where: { registrationKey },
|
|
867
|
+
});
|
|
868
|
+
if (existing?.isEnabled)
|
|
869
|
+
throw new ConflictException('相同的表字段监听已经登记');
|
|
870
|
+
const registration = existing
|
|
871
|
+
? await tx.workflowBusinessEventRegistration.update({
|
|
872
|
+
where: { id: existing.id },
|
|
873
|
+
data: { isEnabled: true, creator },
|
|
874
|
+
})
|
|
875
|
+
: await tx.workflowBusinessEventRegistration.create({
|
|
876
|
+
data: {
|
|
877
|
+
registrationKey,
|
|
878
|
+
schemaName: data.schemaName,
|
|
879
|
+
tableName: data.tableName,
|
|
880
|
+
operation: data.operation,
|
|
881
|
+
watchedFields,
|
|
882
|
+
primaryKeyFields,
|
|
883
|
+
creator,
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
await this.installBusinessEventTrigger(tx, registration);
|
|
887
|
+
return registration;
|
|
888
|
+
});
|
|
889
|
+
this.logger.log(
|
|
890
|
+
`Registered ${event.operation} listener ${event.schemaName}.${event.tableName} (${event.code})`,
|
|
891
|
+
);
|
|
892
|
+
return buildBusinessEventCatalog([event], columns)[0];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private async installBusinessEventTrigger(
|
|
896
|
+
tx: Prisma.TransactionClient,
|
|
897
|
+
event: {
|
|
898
|
+
id: bigint;
|
|
899
|
+
schemaName: string;
|
|
900
|
+
tableName: string;
|
|
901
|
+
operation: string;
|
|
902
|
+
watchedFields: Prisma.JsonValue;
|
|
903
|
+
primaryKeyFields: Prisma.JsonValue;
|
|
904
|
+
},
|
|
905
|
+
) {
|
|
906
|
+
const schema = quoteIdentifier(event.schemaName);
|
|
907
|
+
const table = quoteIdentifier(event.tableName);
|
|
908
|
+
const functionName = quoteIdentifier(`gadmin_wf_event_fn_${event.id}`);
|
|
909
|
+
const triggerName = quoteIdentifier(`gadmin_wf_event_${event.id}`);
|
|
910
|
+
const watchedFields = stringArray(event.watchedFields);
|
|
911
|
+
const primaryKeyFields = stringArray(event.primaryKeyFields);
|
|
912
|
+
const fieldArray = `ARRAY[${watchedFields
|
|
913
|
+
.map(quoteLiteral)
|
|
914
|
+
.join(', ')}]::text[]`;
|
|
915
|
+
const primaryKey = `jsonb_build_object(${primaryKeyFields
|
|
916
|
+
.flatMap((field) => [
|
|
917
|
+
quoteLiteral(field),
|
|
918
|
+
`COALESCE(after_row -> ${quoteLiteral(
|
|
919
|
+
field,
|
|
920
|
+
)}, before_row -> ${quoteLiteral(field)})`,
|
|
921
|
+
])
|
|
922
|
+
.join(', ')})`;
|
|
923
|
+
const workflowSchemaRows = await tx.$queryRaw<
|
|
924
|
+
Array<{ schemaName: string }>
|
|
925
|
+
>`SELECT current_schema() AS "schemaName"`;
|
|
926
|
+
const workflowSchema = quoteIdentifier(
|
|
927
|
+
workflowSchemaRows[0]?.schemaName || 'public',
|
|
928
|
+
);
|
|
929
|
+
const triggerEvent =
|
|
930
|
+
event.operation === 'UPDATE'
|
|
931
|
+
? `UPDATE OF ${watchedFields.map(quoteIdentifier).join(', ')}`
|
|
932
|
+
: event.operation;
|
|
933
|
+
|
|
934
|
+
await tx.$executeRawUnsafe(`
|
|
935
|
+
CREATE OR REPLACE FUNCTION ${schema}.${functionName}()
|
|
936
|
+
RETURNS trigger LANGUAGE plpgsql AS $gadmin_workflow_event$
|
|
937
|
+
DECLARE
|
|
938
|
+
before_row jsonb;
|
|
939
|
+
after_row jsonb;
|
|
940
|
+
changed_fields jsonb;
|
|
941
|
+
event_primary_key jsonb;
|
|
942
|
+
BEGIN
|
|
943
|
+
before_row := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE to_jsonb(OLD) END;
|
|
944
|
+
after_row := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE to_jsonb(NEW) END;
|
|
945
|
+
IF TG_OP = 'UPDATE' THEN
|
|
946
|
+
SELECT COALESCE(jsonb_agg(field_name), '[]'::jsonb)
|
|
947
|
+
INTO changed_fields
|
|
948
|
+
FROM unnest(${fieldArray}) AS field_name
|
|
949
|
+
WHERE before_row -> field_name IS DISTINCT FROM after_row -> field_name;
|
|
950
|
+
IF jsonb_array_length(changed_fields) = 0 THEN RETURN NEW; END IF;
|
|
951
|
+
ELSE
|
|
952
|
+
SELECT COALESCE(jsonb_agg(field_name ORDER BY field_name), '[]'::jsonb)
|
|
953
|
+
INTO changed_fields
|
|
954
|
+
FROM jsonb_object_keys(COALESCE(after_row, before_row)) AS field_name;
|
|
955
|
+
END IF;
|
|
956
|
+
event_primary_key := ${primaryKey};
|
|
957
|
+
INSERT INTO ${workflowSchema}.t_workflow_event_outbox
|
|
958
|
+
(registration_id, operation, schema_name, table_name, primary_key,
|
|
959
|
+
before, after, changed_fields, status, retry_count,
|
|
960
|
+
next_attempt_at, created_at, updated_at)
|
|
961
|
+
VALUES
|
|
962
|
+
(${event.id}, TG_OP::${workflowSchema}."WorkflowEventOperation",
|
|
963
|
+
${quoteLiteral(event.schemaName)}, ${quoteLiteral(event.tableName)},
|
|
964
|
+
event_primary_key, before_row, after_row, changed_fields,
|
|
965
|
+
'PENDING', 0, NOW(), NOW(), NOW());
|
|
966
|
+
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
|
967
|
+
RETURN NEW;
|
|
968
|
+
END;
|
|
969
|
+
$gadmin_workflow_event$;
|
|
970
|
+
`);
|
|
971
|
+
await tx.$executeRawUnsafe(
|
|
972
|
+
`DROP TRIGGER IF EXISTS ${triggerName} ON ${schema}.${table}`,
|
|
973
|
+
);
|
|
974
|
+
await tx.$executeRawUnsafe(`
|
|
975
|
+
CREATE TRIGGER ${triggerName}
|
|
976
|
+
AFTER ${triggerEvent} ON ${schema}.${table}
|
|
977
|
+
FOR EACH ROW EXECUTE FUNCTION ${schema}.${functionName}()
|
|
978
|
+
`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async unregisterBusinessEvent(registrationCode: string) {
|
|
982
|
+
const result = await this.prisma.$transaction(async (tx) => {
|
|
983
|
+
await tx.$queryRaw`
|
|
984
|
+
SELECT pg_advisory_xact_lock(${WORKFLOW_PUBLISH_LOCK_KEY})::text AS "lock"
|
|
985
|
+
`;
|
|
986
|
+
const event = await tx.workflowBusinessEventRegistration.findUnique({
|
|
987
|
+
where: { code: registrationCode },
|
|
988
|
+
});
|
|
989
|
+
if (!event || !event.isEnabled)
|
|
990
|
+
throw new NotFoundException('这个业务事件监听尚未登记');
|
|
991
|
+
|
|
992
|
+
const workflows = await tx.workflow.findMany({
|
|
993
|
+
select: {
|
|
994
|
+
name: true,
|
|
995
|
+
dsl: true,
|
|
996
|
+
versions: {
|
|
997
|
+
orderBy: { version: 'desc' },
|
|
998
|
+
take: 1,
|
|
999
|
+
select: { dsl: true },
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
});
|
|
1003
|
+
const referencingWorkflows = workflows
|
|
1004
|
+
.filter(
|
|
1005
|
+
(workflow) =>
|
|
1006
|
+
dslUsesBusinessEvent(workflow.dsl, registrationCode) ||
|
|
1007
|
+
dslUsesBusinessEvent(workflow.versions[0]?.dsl, registrationCode),
|
|
1008
|
+
)
|
|
1009
|
+
.map((workflow) => workflow.name);
|
|
1010
|
+
if (referencingWorkflows.length > 0) {
|
|
1011
|
+
throw new ConflictException(
|
|
1012
|
+
`以下 Workflow 仍在使用该监听:${referencingWorkflows.join('、')}`,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const schema = quoteIdentifier(event.schemaName);
|
|
1017
|
+
const table = quoteIdentifier(event.tableName);
|
|
1018
|
+
const functionName = quoteIdentifier(`gadmin_wf_event_fn_${event.id}`);
|
|
1019
|
+
const triggerName = quoteIdentifier(`gadmin_wf_event_${event.id}`);
|
|
1020
|
+
await tx.$executeRawUnsafe(
|
|
1021
|
+
`DROP TRIGGER IF EXISTS ${triggerName} ON ${schema}.${table}`,
|
|
1022
|
+
);
|
|
1023
|
+
await tx.$executeRawUnsafe(
|
|
1024
|
+
`DROP FUNCTION IF EXISTS ${schema}.${functionName}()`,
|
|
1025
|
+
);
|
|
1026
|
+
await tx.workflowBusinessEventRegistration.update({
|
|
1027
|
+
where: { id: event.id },
|
|
1028
|
+
data: { isEnabled: false },
|
|
1029
|
+
});
|
|
1030
|
+
return { registrationCode };
|
|
1031
|
+
});
|
|
1032
|
+
this.logger.log(`Unregistered business event listener ${registrationCode}`);
|
|
1033
|
+
return result;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async findBusinessEventCatalogOverview() {
|
|
1037
|
+
const { items } = await this.findBusinessEventCatalog();
|
|
1038
|
+
if (items.length === 0) return { items: [] };
|
|
1039
|
+
|
|
1040
|
+
const workflows = await this.prisma.workflow.findMany({
|
|
1041
|
+
where: { status: 'PUBLISHED', isEnabled: true },
|
|
1042
|
+
select: {
|
|
1043
|
+
id: true,
|
|
1044
|
+
name: true,
|
|
1045
|
+
versions: {
|
|
1046
|
+
orderBy: { version: 'desc' },
|
|
1047
|
+
take: 1,
|
|
1048
|
+
select: { dsl: true },
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
const subscribers = new Map<
|
|
1053
|
+
string,
|
|
1054
|
+
Array<{ workflowId: string; workflowName: string }>
|
|
1055
|
+
>();
|
|
1056
|
+
for (const workflow of workflows) {
|
|
1057
|
+
const dsl = asRecord(workflow.versions[0]?.dsl);
|
|
1058
|
+
const nodes = Array.isArray(dsl.nodes) ? dsl.nodes : [];
|
|
1059
|
+
for (const value of nodes) {
|
|
1060
|
+
const node = asRecord(value);
|
|
1061
|
+
if (node.type !== 'event_trigger') continue;
|
|
1062
|
+
const registrationCode = asRecord(node.config).registrationCode;
|
|
1063
|
+
if (typeof registrationCode !== 'string') continue;
|
|
1064
|
+
const current = subscribers.get(registrationCode) ?? [];
|
|
1065
|
+
current.push({
|
|
1066
|
+
workflowId: workflow.id.toString(),
|
|
1067
|
+
workflowName: workflow.name,
|
|
1068
|
+
});
|
|
1069
|
+
subscribers.set(registrationCode, current);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const registrationCodes = items.map((item) => item.registrationCode);
|
|
1074
|
+
const deliveries = await this.prisma.$queryRaw<EventCatalogDeliveryRow[]>(
|
|
1075
|
+
Prisma.sql`
|
|
1076
|
+
SELECT DISTINCT ON (registration.code)
|
|
1077
|
+
registration.code AS "registrationCode",
|
|
1078
|
+
outbox.created_at AS "createdAt",
|
|
1079
|
+
outbox.status::text AS status,
|
|
1080
|
+
outbox.processed_at AS "processedAt",
|
|
1081
|
+
outbox.retry_count AS "retryCount",
|
|
1082
|
+
outbox.last_error AS "lastError"
|
|
1083
|
+
FROM t_workflow_event_outbox outbox
|
|
1084
|
+
JOIN t_workflow_business_event_registration registration
|
|
1085
|
+
ON registration.id = outbox.registration_id
|
|
1086
|
+
WHERE registration.code IN (${Prisma.join(registrationCodes)})
|
|
1087
|
+
ORDER BY registration.code, outbox.created_at DESC, outbox.id DESC
|
|
1088
|
+
`,
|
|
1089
|
+
);
|
|
1090
|
+
const latestDelivery = new Map(
|
|
1091
|
+
deliveries.map((delivery) => [delivery.registrationCode, delivery]),
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
return {
|
|
1095
|
+
items: items.map((item) => ({
|
|
1096
|
+
...item,
|
|
1097
|
+
subscribers: subscribers.get(item.registrationCode) ?? [],
|
|
1098
|
+
latestDelivery: latestDelivery.get(item.registrationCode),
|
|
1099
|
+
})),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async findBusinessEventInstances(
|
|
1104
|
+
registrationCode: string,
|
|
1105
|
+
params: { status?: string; skip?: number; limit?: number },
|
|
1106
|
+
) {
|
|
1107
|
+
const event =
|
|
1108
|
+
await this.prisma.workflowBusinessEventRegistration.findUnique({
|
|
1109
|
+
where: { code: registrationCode },
|
|
1110
|
+
});
|
|
1111
|
+
if (!event) throw new NotFoundException('业务事件不存在');
|
|
1112
|
+
const columns = await this.findBusinessEventColumns();
|
|
1113
|
+
|
|
1114
|
+
const statusFilter = params.status
|
|
1115
|
+
? Prisma.sql`AND instance.status::text = ${params.status}`
|
|
1116
|
+
: Prisma.empty;
|
|
1117
|
+
const instanceTree = Prisma.sql`
|
|
1118
|
+
WITH RECURSIVE event_instance_tree AS (
|
|
1119
|
+
SELECT instance.id
|
|
1120
|
+
FROM t_workflow_instance instance
|
|
1121
|
+
JOIN t_workflow_event_outbox source_event
|
|
1122
|
+
ON source_event.id = instance.source_outbox_id
|
|
1123
|
+
WHERE source_event.registration_id = ${event.id}
|
|
1124
|
+
|
|
1125
|
+
UNION ALL
|
|
1126
|
+
|
|
1127
|
+
SELECT retry.id
|
|
1128
|
+
FROM t_workflow_instance retry
|
|
1129
|
+
JOIN event_instance_tree parent
|
|
1130
|
+
ON retry.retry_of_instance_id = parent.id
|
|
1131
|
+
)
|
|
1132
|
+
`;
|
|
1133
|
+
const skip = params.skip ?? 0;
|
|
1134
|
+
const limit = params.limit ?? 20;
|
|
1135
|
+
const [rows, countRows] = await Promise.all([
|
|
1136
|
+
this.prisma.$queryRaw<BusinessEventInstanceRow[]>(Prisma.sql`
|
|
1137
|
+
${instanceTree}
|
|
1138
|
+
SELECT
|
|
1139
|
+
instance.id,
|
|
1140
|
+
instance.workflow_id AS "workflowId",
|
|
1141
|
+
workflow.name AS "workflowName",
|
|
1142
|
+
version.version,
|
|
1143
|
+
instance.status::text AS status,
|
|
1144
|
+
instance.retry_of_instance_id AS "retryOfInstanceId",
|
|
1145
|
+
instance.started_at AS "startedAt",
|
|
1146
|
+
instance.finished_at AS "finishedAt",
|
|
1147
|
+
instance.created_at AS "createdAt"
|
|
1148
|
+
FROM event_instance_tree tree
|
|
1149
|
+
JOIN t_workflow_instance instance ON instance.id = tree.id
|
|
1150
|
+
JOIN t_workflow workflow ON workflow.id = instance.workflow_id
|
|
1151
|
+
JOIN t_workflow_version version ON version.id = instance.version_id
|
|
1152
|
+
WHERE TRUE ${statusFilter}
|
|
1153
|
+
ORDER BY instance.created_at DESC, instance.id DESC
|
|
1154
|
+
OFFSET ${skip}
|
|
1155
|
+
LIMIT ${limit}
|
|
1156
|
+
`),
|
|
1157
|
+
this.prisma.$queryRaw<Array<{ total: bigint }>>(Prisma.sql`
|
|
1158
|
+
${instanceTree}
|
|
1159
|
+
SELECT COUNT(*)::bigint AS total
|
|
1160
|
+
FROM event_instance_tree tree
|
|
1161
|
+
JOIN t_workflow_instance instance ON instance.id = tree.id
|
|
1162
|
+
WHERE TRUE ${statusFilter}
|
|
1163
|
+
`),
|
|
1164
|
+
]);
|
|
1165
|
+
|
|
1166
|
+
return {
|
|
1167
|
+
event: buildBusinessEventCatalog([event], columns)[0],
|
|
1168
|
+
items: rows.map((row) => ({
|
|
1169
|
+
...row,
|
|
1170
|
+
id: row.id.toString(),
|
|
1171
|
+
workflowId: row.workflowId.toString(),
|
|
1172
|
+
retryOfInstanceId: row.retryOfInstanceId?.toString() ?? null,
|
|
1173
|
+
})),
|
|
1174
|
+
total: Number(countRows[0]?.total ?? 0),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
257
1178
|
async findOne(id: bigint) {
|
|
258
1179
|
const workflow = await this.prisma.workflow.findUnique({
|
|
259
1180
|
where: { id },
|
|
@@ -265,14 +1186,22 @@ export class WorkflowService {
|
|
|
265
1186
|
},
|
|
266
1187
|
});
|
|
267
1188
|
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
268
|
-
|
|
1189
|
+
const hasCronSchedule = Boolean(
|
|
1190
|
+
this.extractCronConfig(workflow.versions[0]?.dsl),
|
|
1191
|
+
);
|
|
1192
|
+
return {
|
|
1193
|
+
...workflow,
|
|
1194
|
+
hasUnpublishedChanges: this.hasUnpublishedChanges(workflow),
|
|
1195
|
+
scheduleStatus: hasCronSchedule
|
|
1196
|
+
? await this.temporalService.getCronScheduleStatus(Number(id))
|
|
1197
|
+
: 'NOT_APPLICABLE',
|
|
1198
|
+
};
|
|
269
1199
|
}
|
|
270
1200
|
|
|
271
1201
|
async create(
|
|
272
1202
|
data: { name: string; description?: string; dsl?: any },
|
|
273
1203
|
creator: string,
|
|
274
1204
|
) {
|
|
275
|
-
this.validateCallableDraft(data.dsl);
|
|
276
1205
|
const workflow = await this.prisma.workflow.create({
|
|
277
1206
|
data: {
|
|
278
1207
|
name: data.name,
|
|
@@ -292,71 +1221,96 @@ export class WorkflowService {
|
|
|
292
1221
|
}
|
|
293
1222
|
|
|
294
1223
|
async saveDraft(id: bigint, dsl: any) {
|
|
295
|
-
const workflow = await this.prisma.workflow.findUnique({
|
|
296
|
-
where: { id },
|
|
297
|
-
include: {
|
|
298
|
-
versions: {
|
|
299
|
-
orderBy: { version: 'desc' },
|
|
300
|
-
take: 1,
|
|
301
|
-
select: { dsl: true },
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
});
|
|
1224
|
+
const workflow = await this.prisma.workflow.findUnique({ where: { id } });
|
|
305
1225
|
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
306
|
-
this.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
1226
|
+
if (workflow.status === 'DELETING') throw this.workflowDeletingError();
|
|
1227
|
+
return this.prisma.$transaction(async (tx) => {
|
|
1228
|
+
const locked = await tx.$queryRaw<Array<{ status: string }>>`
|
|
1229
|
+
SELECT status::text AS status
|
|
1230
|
+
FROM t_workflow
|
|
1231
|
+
WHERE id = ${id}
|
|
1232
|
+
FOR UPDATE`;
|
|
1233
|
+
if (!locked[0]) throw new NotFoundException('Workflow not found');
|
|
1234
|
+
if (locked[0].status === 'DELETING') throw this.workflowDeletingError();
|
|
1235
|
+
|
|
1236
|
+
const latestVersion = await tx.workflowVersion.findFirst({
|
|
1237
|
+
where: { workflowId: id },
|
|
1238
|
+
orderBy: { version: 'desc' },
|
|
1239
|
+
select: { dsl: true },
|
|
1240
|
+
});
|
|
1241
|
+
const status = latestVersion ? 'PUBLISHED' : 'DRAFT';
|
|
1242
|
+
const updated = await tx.workflow.update({
|
|
1243
|
+
where: { id },
|
|
1244
|
+
data: { dsl, status },
|
|
1245
|
+
});
|
|
1246
|
+
return {
|
|
1247
|
+
...updated,
|
|
1248
|
+
hasUnpublishedChanges: Boolean(
|
|
1249
|
+
!latestVersion || !areWorkflowDslSourcesEqual(dsl, latestVersion.dsl),
|
|
1250
|
+
),
|
|
1251
|
+
};
|
|
315
1252
|
});
|
|
316
1253
|
}
|
|
317
1254
|
|
|
318
1255
|
async toggleEnabled(id: bigint, enabled: boolean) {
|
|
319
|
-
const workflow = await this.prisma
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
1256
|
+
const workflow = await this.prisma.$transaction(async (tx) => {
|
|
1257
|
+
const changed = await tx.workflow.updateMany({
|
|
1258
|
+
where: { id, status: { not: 'DELETING' } },
|
|
1259
|
+
data: { isEnabled: enabled },
|
|
1260
|
+
});
|
|
1261
|
+
if (changed.count !== 1) {
|
|
1262
|
+
const existing = await tx.workflow.findUnique({ where: { id } });
|
|
1263
|
+
if (!existing) throw new NotFoundException('Workflow not found');
|
|
1264
|
+
throw this.workflowDeletingError();
|
|
1265
|
+
}
|
|
325
1266
|
|
|
326
|
-
|
|
1267
|
+
return tx.workflow.findUniqueOrThrow({ where: { id } });
|
|
1268
|
+
});
|
|
327
1269
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
} else {
|
|
331
|
-
await this.temporalService.pauseCronSchedule(Number(id));
|
|
332
|
-
}
|
|
1270
|
+
const active = await this.reconcileCronScheduleCurrent(id);
|
|
1271
|
+
if (!active) throw this.workflowDeletingError();
|
|
333
1272
|
} catch (err: any) {
|
|
334
|
-
this.logger.
|
|
335
|
-
`Failed to ${
|
|
336
|
-
|
|
337
|
-
}
|
|
1273
|
+
this.logger.error(
|
|
1274
|
+
`Failed to synchronize cron schedule for workflow ${id}: ${
|
|
1275
|
+
err?.message ?? err
|
|
1276
|
+
}`,
|
|
338
1277
|
);
|
|
1278
|
+
throw err;
|
|
339
1279
|
}
|
|
340
|
-
|
|
341
|
-
return updated;
|
|
1280
|
+
return workflow;
|
|
342
1281
|
}
|
|
343
1282
|
|
|
344
1283
|
async remove(id: bigint) {
|
|
345
1284
|
const workflow = await this.prisma.workflow.findUnique({ where: { id } });
|
|
346
1285
|
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
1286
|
+
await this.assertNoWorkflowCallers(id);
|
|
347
1287
|
|
|
348
|
-
//
|
|
349
|
-
|
|
1288
|
+
// Phase 1 makes the Workflow unavailable to new executions. The active
|
|
1289
|
+
// instance check is performed while the row is locked by the update.
|
|
1290
|
+
const hadCronSchedule = await this.prisma.$transaction(async (tx) => {
|
|
1291
|
+
await tx.workflow.update({
|
|
1292
|
+
where: { id },
|
|
1293
|
+
data: { isEnabled: false, status: 'DELETING' },
|
|
1294
|
+
});
|
|
1295
|
+
await this.assertNoActiveInstances(tx, id);
|
|
1296
|
+
const versions = await tx.workflowVersion.findMany({
|
|
1297
|
+
where: { workflowId: id },
|
|
1298
|
+
select: { dsl: true },
|
|
1299
|
+
});
|
|
1300
|
+
return this.hasCronHistory(versions);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// A failed external delete leaves a disabled, recoverable Workflow row.
|
|
1304
|
+
if (hadCronSchedule)
|
|
350
1305
|
await this.temporalService.deleteCronSchedule(Number(id));
|
|
351
|
-
} catch (err: any) {
|
|
352
|
-
this.logger.warn(
|
|
353
|
-
`Failed to delete cron schedule for workflow ${id}: ${
|
|
354
|
-
err?.message ?? err
|
|
355
|
-
}`,
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
1306
|
|
|
359
1307
|
return this.prisma.$transaction(async (tx) => {
|
|
1308
|
+
await tx.workflow.update({
|
|
1309
|
+
where: { id },
|
|
1310
|
+
data: { isEnabled: false, status: 'DELETING' },
|
|
1311
|
+
});
|
|
1312
|
+
await this.assertNoActiveInstances(tx, id);
|
|
1313
|
+
|
|
360
1314
|
const interactions = await tx.externalInteraction.findMany({
|
|
361
1315
|
where: {
|
|
362
1316
|
OR: [
|
|
@@ -381,6 +1335,117 @@ export class WorkflowService {
|
|
|
381
1335
|
});
|
|
382
1336
|
}
|
|
383
1337
|
|
|
1338
|
+
private async assertNoActiveInstances(
|
|
1339
|
+
tx: Pick<Prisma.TransactionClient, 'workflowInstance'>,
|
|
1340
|
+
workflowId: bigint,
|
|
1341
|
+
): Promise<void> {
|
|
1342
|
+
const activeInstances = await tx.workflowInstance.count({
|
|
1343
|
+
where: {
|
|
1344
|
+
workflowId,
|
|
1345
|
+
status: { in: ['PENDING', 'RUNNING'] },
|
|
1346
|
+
},
|
|
1347
|
+
});
|
|
1348
|
+
if (activeInstances > 0)
|
|
1349
|
+
throw new ConflictException({
|
|
1350
|
+
code: 'WORKFLOW_HAS_ACTIVE_INSTANCES',
|
|
1351
|
+
message: '存在运行中或等待中的实例,不能删除 Workflow',
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
private async assertNoWorkflowCallers(workflowId: bigint): Promise<void> {
|
|
1356
|
+
const workflows = await this.prisma.workflow.findMany({
|
|
1357
|
+
where: { id: { not: workflowId } },
|
|
1358
|
+
select: {
|
|
1359
|
+
id: true,
|
|
1360
|
+
name: true,
|
|
1361
|
+
dsl: true,
|
|
1362
|
+
versions: { select: { version: true, dsl: true } },
|
|
1363
|
+
},
|
|
1364
|
+
});
|
|
1365
|
+
const dsls = (workflows ?? []).flatMap((workflow) => [
|
|
1366
|
+
workflow.dsl,
|
|
1367
|
+
...(workflow.versions ?? []).map((version) => version.dsl),
|
|
1368
|
+
]);
|
|
1369
|
+
const referencedInstanceIds = [
|
|
1370
|
+
...new Set(
|
|
1371
|
+
dsls.flatMap((dsl) => {
|
|
1372
|
+
const nodes = Array.isArray(asRecord(dsl).nodes)
|
|
1373
|
+
? (asRecord(dsl).nodes as unknown[])
|
|
1374
|
+
: [];
|
|
1375
|
+
return nodes.flatMap((value) => {
|
|
1376
|
+
const node = asRecord(value);
|
|
1377
|
+
if (node.type !== 'sub_workflow') return [];
|
|
1378
|
+
const instanceId = String(
|
|
1379
|
+
asRecord(node.instanceRef).instanceId ?? '',
|
|
1380
|
+
);
|
|
1381
|
+
return /^[1-9]\d*$/.test(instanceId) ? [instanceId] : [];
|
|
1382
|
+
});
|
|
1383
|
+
}),
|
|
1384
|
+
),
|
|
1385
|
+
];
|
|
1386
|
+
const referencedInstances = referencedInstanceIds.length
|
|
1387
|
+
? await this.prisma.workflowNodeInstance.findMany({
|
|
1388
|
+
where: {
|
|
1389
|
+
id: { in: referencedInstanceIds.map((id) => BigInt(id)) },
|
|
1390
|
+
},
|
|
1391
|
+
select: { id: true, config: true },
|
|
1392
|
+
})
|
|
1393
|
+
: [];
|
|
1394
|
+
const instanceConfigs = new Map(
|
|
1395
|
+
referencedInstances.map((instance) => [
|
|
1396
|
+
String(instance.id),
|
|
1397
|
+
asRecord(instance.config),
|
|
1398
|
+
]),
|
|
1399
|
+
);
|
|
1400
|
+
const callers: Array<{
|
|
1401
|
+
workflowId: string;
|
|
1402
|
+
workflowName: string;
|
|
1403
|
+
source: 'DRAFT' | 'PUBLISHED';
|
|
1404
|
+
version?: number;
|
|
1405
|
+
}> = [];
|
|
1406
|
+
const referencesTarget = (dsl: unknown) => {
|
|
1407
|
+
const nodes = Array.isArray(asRecord(dsl).nodes)
|
|
1408
|
+
? (asRecord(dsl).nodes as unknown[])
|
|
1409
|
+
: [];
|
|
1410
|
+
return nodes.some((value) => {
|
|
1411
|
+
const node = asRecord(value);
|
|
1412
|
+
if (node.type !== 'sub_workflow') return false;
|
|
1413
|
+
const override = node.configOverride;
|
|
1414
|
+
const instanceId = String(asRecord(node.instanceRef).instanceId ?? '');
|
|
1415
|
+
const config =
|
|
1416
|
+
override && typeof override === 'object' && !Array.isArray(override)
|
|
1417
|
+
? asRecord(override)
|
|
1418
|
+
: instanceConfigs.get(instanceId) ?? asRecord(node.config);
|
|
1419
|
+
return String(config.workflowId ?? '') === String(workflowId);
|
|
1420
|
+
});
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
for (const workflow of workflows ?? []) {
|
|
1424
|
+
if (referencesTarget(workflow.dsl))
|
|
1425
|
+
callers.push({
|
|
1426
|
+
workflowId: String(workflow.id),
|
|
1427
|
+
workflowName: workflow.name,
|
|
1428
|
+
source: 'DRAFT',
|
|
1429
|
+
});
|
|
1430
|
+
for (const version of workflow.versions ?? []) {
|
|
1431
|
+
if (!referencesTarget(version.dsl)) continue;
|
|
1432
|
+
callers.push({
|
|
1433
|
+
workflowId: String(workflow.id),
|
|
1434
|
+
workflowName: workflow.name,
|
|
1435
|
+
source: 'PUBLISHED',
|
|
1436
|
+
version: version.version,
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (callers.length > 0)
|
|
1442
|
+
throw new ConflictException({
|
|
1443
|
+
code: 'WORKFLOW_IS_REFERENCED',
|
|
1444
|
+
message: '该 Workflow 仍被其他 Workflow 调用,解除引用后才能删除',
|
|
1445
|
+
callers,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
384
1449
|
async getVersions(workflowId: bigint) {
|
|
385
1450
|
return this.prisma.workflowVersion.findMany({
|
|
386
1451
|
where: { workflowId },
|
|
@@ -415,11 +1480,12 @@ export class WorkflowService {
|
|
|
415
1480
|
versions: {
|
|
416
1481
|
orderBy: { version: 'desc' },
|
|
417
1482
|
take: 1,
|
|
418
|
-
select: { dsl: true },
|
|
1483
|
+
select: { id: true, version: true, dsl: true },
|
|
419
1484
|
},
|
|
420
1485
|
},
|
|
421
1486
|
});
|
|
422
1487
|
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
1488
|
+
if (workflow.status === 'DELETING') throw this.workflowDeletingError();
|
|
423
1489
|
|
|
424
1490
|
// Use workflow.dsl if available; otherwise fall back to latest version's DSL
|
|
425
1491
|
let dslToPublish = workflow.dsl;
|
|
@@ -429,17 +1495,6 @@ export class WorkflowService {
|
|
|
429
1495
|
if (!dslToPublish) {
|
|
430
1496
|
throw new NotFoundException('No draft DSL to publish');
|
|
431
1497
|
}
|
|
432
|
-
const latestPublishedDsl = workflow.versions?.[0]?.dsl;
|
|
433
|
-
if (
|
|
434
|
-
latestPublishedDsl &&
|
|
435
|
-
areWorkflowDslSourcesEqual(dslToPublish, latestPublishedDsl)
|
|
436
|
-
) {
|
|
437
|
-
throw new BadRequestException({
|
|
438
|
-
code: 'WORKFLOW_DSL_UNCHANGED',
|
|
439
|
-
message: '保存后的 Workflow DSL 与最新发布版本相同,无需再次发布',
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
443
1498
|
// Structural + cycle validation. Reject malformed/cyclic DSL up-front so the
|
|
444
1499
|
// worker's executeGraph never has to deal with them at runtime.
|
|
445
1500
|
try {
|
|
@@ -448,10 +1503,18 @@ export class WorkflowService {
|
|
|
448
1503
|
throw new BadRequestException(`Invalid DSL: ${err?.message ?? err}`);
|
|
449
1504
|
}
|
|
450
1505
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
1506
|
+
const compiledDsl = await this.workflowCompiler.compile(
|
|
1507
|
+
dslToPublish as unknown as DraftWorkflowDsl,
|
|
1508
|
+
{ userId: creator, username: creator },
|
|
1509
|
+
{ workflowId },
|
|
1510
|
+
);
|
|
1511
|
+
const cronConfig = this.extractCronConfig(compiledDsl);
|
|
1512
|
+
if (cronConfig) this.validateCronConfig(cronConfig);
|
|
1513
|
+
|
|
1514
|
+
// Validate FLOW handles against the same node-type metadata consumed by
|
|
1515
|
+
// the canvas. Use compiled nodes so instance config overrides and dynamic
|
|
1516
|
+
// outputs are validated against the effective runtime configuration.
|
|
1517
|
+
const handleDsl = compiledDsl as any;
|
|
455
1518
|
const nodeTypes = await this.prisma.workflowNodeType.findMany({
|
|
456
1519
|
select: { type: true, outputs: true },
|
|
457
1520
|
});
|
|
@@ -470,28 +1533,20 @@ export class WorkflowService {
|
|
|
470
1533
|
const handleErrors = validateWorkflowHandles(
|
|
471
1534
|
{
|
|
472
1535
|
...handleDsl,
|
|
473
|
-
edges: (handleDsl.edges ?? [])
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
['response-out', 'result-out'].includes(edge.sourceHandle)
|
|
477
|
-
? { ...edge, sourceHandle: undefined }
|
|
478
|
-
: edge,
|
|
479
|
-
),
|
|
1536
|
+
edges: (handleDsl.edges ?? []).filter(
|
|
1537
|
+
(edge: any) => !edge.kind || edge.kind === 'FLOW',
|
|
1538
|
+
),
|
|
480
1539
|
},
|
|
481
1540
|
outputsMap,
|
|
482
1541
|
);
|
|
483
1542
|
if (handleErrors.length > 0) {
|
|
484
1543
|
throw new BadRequestException({
|
|
485
|
-
message: 'DSL validation failed: invalid edge
|
|
1544
|
+
message: 'DSL validation failed: invalid edge handle(s)',
|
|
486
1545
|
code: 'DSL_HANDLE_VALIDATION',
|
|
487
1546
|
errors: handleErrors,
|
|
488
1547
|
});
|
|
489
1548
|
}
|
|
490
1549
|
|
|
491
|
-
const compiledDsl = await this.workflowCompiler.compile(
|
|
492
|
-
dslToPublish as unknown as DraftWorkflowDsl,
|
|
493
|
-
{ userId: creator, username: creator },
|
|
494
|
-
);
|
|
495
1550
|
try {
|
|
496
1551
|
validateWorkflowDataEdges(compiledDsl as any);
|
|
497
1552
|
} catch (error) {
|
|
@@ -502,46 +1557,63 @@ export class WorkflowService {
|
|
|
502
1557
|
);
|
|
503
1558
|
}
|
|
504
1559
|
|
|
505
|
-
|
|
1560
|
+
if (compiledDsl.nodes.some((node) => node.type === 'event_trigger')) {
|
|
1561
|
+
const catalog = await this.findBusinessEventCatalog();
|
|
1562
|
+
const unknownEvents = unknownBusinessEventNames(
|
|
1563
|
+
compiledDsl,
|
|
1564
|
+
catalog.items,
|
|
1565
|
+
);
|
|
1566
|
+
if (unknownEvents.length > 0)
|
|
1567
|
+
throw new BadRequestException({
|
|
1568
|
+
code: 'WORKFLOW_EVENT_NOT_REGISTERED',
|
|
1569
|
+
message: `未登记的数据库监听:${unknownEvents.join(', ')}`,
|
|
1570
|
+
registrationCodes: unknownEvents,
|
|
1571
|
+
});
|
|
1572
|
+
validateBusinessEventTriggers(compiledDsl, catalog.items);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
506
1575
|
const webhookConfigs = this.extractWebhookConfigs(compiledDsl);
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
throw new Error(
|
|
523
|
-
`webhook_trigger path="${cfg.method} ${cfg.path}" requires a secret (HMAC strict mode)`,
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
const conflict = otherPublished.find((w) =>
|
|
527
|
-
this.extractWebhookConfigs(w.versions[0]?.dsl).some(
|
|
528
|
-
(other) => other.path === cfg.path && other.method === cfg.method,
|
|
529
|
-
),
|
|
1576
|
+
for (const node of compiledDsl.nodes.filter(
|
|
1577
|
+
(item) => item.type === 'webhook_trigger',
|
|
1578
|
+
)) {
|
|
1579
|
+
const path = String((node.config as any)?.path ?? '');
|
|
1580
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?$/.test(path))
|
|
1581
|
+
throw new BadRequestException({
|
|
1582
|
+
code: 'WORKFLOW_WEBHOOK_PATH_INVALID',
|
|
1583
|
+
message:
|
|
1584
|
+
'Webhook 路径必须是非空的单段路径,只能包含字母、数字、- 和 _',
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
for (const cfg of webhookConfigs) {
|
|
1588
|
+
if (cfg.verificationMode === 'HMAC' && !cfg.secret) {
|
|
1589
|
+
throw new Error(
|
|
1590
|
+
`webhook_trigger path="${cfg.method} ${cfg.path}" requires a secret (HMAC strict mode)`,
|
|
530
1591
|
);
|
|
531
|
-
if (conflict) {
|
|
532
|
-
throw new Error(
|
|
533
|
-
`webhook path "${cfg.method} ${cfg.path}" already used by workflow #${conflict.id} (${conflict.name})`,
|
|
534
|
-
);
|
|
535
|
-
}
|
|
536
1592
|
}
|
|
537
1593
|
}
|
|
538
1594
|
|
|
539
1595
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
1596
|
+
// Serialize publication across application replicas so version allocation
|
|
1597
|
+
// remains deterministic.
|
|
1598
|
+
await tx.$queryRaw`
|
|
1599
|
+
SELECT pg_advisory_xact_lock(${WORKFLOW_PUBLISH_LOCK_KEY})::text AS "lock"
|
|
1600
|
+
`;
|
|
1601
|
+
const active = await tx.workflow.updateMany({
|
|
1602
|
+
where: { id: workflowId, status: { not: 'DELETING' } },
|
|
1603
|
+
data: { status: 'PUBLISHED' },
|
|
1604
|
+
});
|
|
1605
|
+
if (active.count !== 1) throw this.workflowDeletingError();
|
|
1606
|
+
|
|
540
1607
|
const lastVersion = await tx.workflowVersion.findFirst({
|
|
541
1608
|
where: { workflowId },
|
|
542
1609
|
orderBy: { version: 'desc' },
|
|
543
|
-
select: { version: true },
|
|
1610
|
+
select: { id: true, version: true, dsl: true },
|
|
544
1611
|
});
|
|
1612
|
+
if (
|
|
1613
|
+
lastVersion &&
|
|
1614
|
+
areWorkflowDslSourcesEqual(dslToPublish, lastVersion.dsl)
|
|
1615
|
+
)
|
|
1616
|
+
return lastVersion;
|
|
545
1617
|
const nextVersion = (lastVersion?.version ?? 0) + 1;
|
|
546
1618
|
|
|
547
1619
|
const version = await tx.workflowVersion.create({
|
|
@@ -555,11 +1627,6 @@ export class WorkflowService {
|
|
|
555
1627
|
},
|
|
556
1628
|
});
|
|
557
1629
|
|
|
558
|
-
await tx.workflow.update({
|
|
559
|
-
where: { id: workflowId },
|
|
560
|
-
data: { status: 'PUBLISHED' },
|
|
561
|
-
});
|
|
562
|
-
|
|
563
1630
|
await tx.audit.create({
|
|
564
1631
|
data: {
|
|
565
1632
|
creator,
|
|
@@ -580,27 +1647,15 @@ export class WorkflowService {
|
|
|
580
1647
|
return version;
|
|
581
1648
|
});
|
|
582
1649
|
|
|
583
|
-
// Cron schedule lifecycle: register/update if cron_trigger present, delete otherwise
|
|
584
|
-
const cronConfig = this.extractCronConfig(result.dsl);
|
|
585
1650
|
try {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
workflowId: Number(workflowId),
|
|
589
|
-
versionId: Number(result.id),
|
|
590
|
-
dsl: result.dsl,
|
|
591
|
-
cron: cronConfig.cron,
|
|
592
|
-
timezone: cronConfig.timezone,
|
|
593
|
-
});
|
|
594
|
-
} else {
|
|
595
|
-
// No cron_trigger in this version → clean up any leftover schedule
|
|
596
|
-
await this.temporalService.deleteCronSchedule(Number(workflowId));
|
|
597
|
-
}
|
|
1651
|
+
const active = await this.reconcileCronScheduleCurrent(workflowId);
|
|
1652
|
+
if (!active) throw this.workflowDeletingError();
|
|
598
1653
|
} catch (err: any) {
|
|
599
|
-
this.logger.
|
|
600
|
-
`Cron schedule sync failed for workflow ${workflowId}
|
|
601
|
-
|
|
602
|
-
}`,
|
|
1654
|
+
this.logger.error(
|
|
1655
|
+
`Cron schedule sync failed for workflow ${workflowId}`,
|
|
1656
|
+
err?.stack ?? String(err),
|
|
603
1657
|
);
|
|
1658
|
+
throw err;
|
|
604
1659
|
}
|
|
605
1660
|
|
|
606
1661
|
return result;
|
|
@@ -622,6 +1677,15 @@ export class WorkflowService {
|
|
|
622
1677
|
return { running, failed, completed, total };
|
|
623
1678
|
}
|
|
624
1679
|
|
|
1680
|
+
async getWorkflowStatsByCode(code: string) {
|
|
1681
|
+
const workflow = await this.prisma.workflow.findUnique({
|
|
1682
|
+
where: { code: code.trim() },
|
|
1683
|
+
select: { id: true },
|
|
1684
|
+
});
|
|
1685
|
+
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
1686
|
+
return this.getStats(workflow.id);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
625
1689
|
async getOverview() {
|
|
626
1690
|
const now = new Date();
|
|
627
1691
|
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
@@ -730,9 +1794,12 @@ export class WorkflowService {
|
|
|
730
1794
|
workflow?: Record<string, any>;
|
|
731
1795
|
versionId?: number;
|
|
732
1796
|
workflowVersionId?: bigint;
|
|
1797
|
+
idempotencyKey?: string;
|
|
1798
|
+
idempotencyIgnoredWorkflowFields?: string[];
|
|
733
1799
|
},
|
|
734
1800
|
creator: string,
|
|
735
|
-
temporalService:
|
|
1801
|
+
temporalService: TemporalService,
|
|
1802
|
+
internal?: WorkflowExecutionInternalOptions,
|
|
736
1803
|
) {
|
|
737
1804
|
// Get the workflow and its latest published version
|
|
738
1805
|
const workflow = await this.prisma.workflow.findUnique({
|
|
@@ -746,9 +1813,7 @@ export class WorkflowService {
|
|
|
746
1813
|
});
|
|
747
1814
|
|
|
748
1815
|
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
749
|
-
if (workflow.status
|
|
750
|
-
throw new Error('Workflow must be published before execution');
|
|
751
|
-
}
|
|
1816
|
+
if (workflow.status === 'DELETING') throw this.workflowDeletingError();
|
|
752
1817
|
if (!workflow.isEnabled) {
|
|
753
1818
|
throw new Error('Workflow is disabled');
|
|
754
1819
|
}
|
|
@@ -769,40 +1834,193 @@ export class WorkflowService {
|
|
|
769
1834
|
|
|
770
1835
|
if (!version) throw new NotFoundException('No published version found');
|
|
771
1836
|
|
|
772
|
-
const executionContext = createWorkflowExecutionState(
|
|
1837
|
+
const executionContext = createWorkflowExecutionState(
|
|
1838
|
+
dto.workflow,
|
|
1839
|
+
internal?.operationScopeId,
|
|
1840
|
+
internal?.retryCheckpoint,
|
|
1841
|
+
);
|
|
1842
|
+
const idempotencyRequestHash = dto.idempotencyKey
|
|
1843
|
+
? workflowDataHash(
|
|
1844
|
+
dto.workflow ?? {},
|
|
1845
|
+
dto.idempotencyIgnoredWorkflowFields,
|
|
1846
|
+
)
|
|
1847
|
+
: undefined;
|
|
1848
|
+
|
|
1849
|
+
let instance;
|
|
1850
|
+
try {
|
|
1851
|
+
instance = await this.prisma.$transaction(async (tx) => {
|
|
1852
|
+
const lockedWorkflow = await tx.$queryRaw<
|
|
1853
|
+
Array<{ isEnabled: boolean; status: string }>
|
|
1854
|
+
>`SELECT is_enabled AS "isEnabled", status::text AS status
|
|
1855
|
+
FROM t_workflow
|
|
1856
|
+
WHERE id = ${workflowId}
|
|
1857
|
+
FOR UPDATE`;
|
|
1858
|
+
if (!lockedWorkflow[0])
|
|
1859
|
+
throw new NotFoundException('Workflow not found');
|
|
1860
|
+
if (lockedWorkflow[0].status === 'DELETING')
|
|
1861
|
+
throw this.workflowDeletingError();
|
|
1862
|
+
if (!lockedWorkflow[0].isEnabled)
|
|
1863
|
+
throw new Error('Workflow is disabled');
|
|
1864
|
+
return tx.workflowInstance.create({
|
|
1865
|
+
data: {
|
|
1866
|
+
workflowId,
|
|
1867
|
+
versionId: version.id,
|
|
1868
|
+
status: 'PENDING',
|
|
1869
|
+
context: executionContext,
|
|
1870
|
+
...(dto.idempotencyKey && {
|
|
1871
|
+
idempotencyKey: dto.idempotencyKey,
|
|
1872
|
+
idempotencyRequestHash,
|
|
1873
|
+
}),
|
|
1874
|
+
...(internal?.retryOfInstanceId && {
|
|
1875
|
+
retryOfInstanceId: internal.retryOfInstanceId,
|
|
1876
|
+
retryFromNodeId: internal.retryFromNodeId,
|
|
1877
|
+
}),
|
|
1878
|
+
creator,
|
|
1879
|
+
},
|
|
1880
|
+
});
|
|
1881
|
+
});
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
if (
|
|
1884
|
+
!dto.idempotencyKey ||
|
|
1885
|
+
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
|
|
1886
|
+
error.code !== 'P2002'
|
|
1887
|
+
)
|
|
1888
|
+
throw error;
|
|
773
1889
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1890
|
+
const existing = await this.prisma.workflowInstance.findUnique({
|
|
1891
|
+
where: {
|
|
1892
|
+
workflowId_idempotencyKey: {
|
|
1893
|
+
workflowId,
|
|
1894
|
+
idempotencyKey: dto.idempotencyKey,
|
|
1895
|
+
},
|
|
1896
|
+
},
|
|
1897
|
+
});
|
|
1898
|
+
if (!existing) throw error;
|
|
1899
|
+
if (existing.idempotencyRequestHash !== idempotencyRequestHash) {
|
|
1900
|
+
throw new ConflictException({
|
|
1901
|
+
code: 'IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_INPUT',
|
|
1902
|
+
message: '同一个 Idempotency-Key 不能用于不同的启动数据',
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
instance = existing;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
if (instance.status !== 'PENDING') return workflowExecutionResult(instance);
|
|
1909
|
+
|
|
1910
|
+
const instanceVersion =
|
|
1911
|
+
instance.versionId === version.id
|
|
1912
|
+
? version
|
|
1913
|
+
: await this.prisma.workflowVersion.findUnique({
|
|
1914
|
+
where: { id: instance.versionId },
|
|
1915
|
+
});
|
|
1916
|
+
if (!instanceVersion)
|
|
1917
|
+
throw new NotFoundException('Workflow version not found');
|
|
1918
|
+
|
|
1919
|
+
const temporalRunId =
|
|
1920
|
+
instance.temporalRunId ??
|
|
1921
|
+
workflowTemporalId(Number(workflowId), Number(instance.id));
|
|
1922
|
+
let launchReservedByCaller = false;
|
|
1923
|
+
if (!instance.temporalRunId) {
|
|
1924
|
+
const reserved = await this.prisma.workflowInstance.updateMany({
|
|
1925
|
+
where: { id: instance.id, status: 'PENDING', temporalRunId: null },
|
|
1926
|
+
data: { temporalRunId },
|
|
1927
|
+
});
|
|
1928
|
+
if (reserved.count !== 1) {
|
|
1929
|
+
const current = await this.prisma.workflowInstance.findUnique({
|
|
1930
|
+
where: { id: instance.id },
|
|
1931
|
+
});
|
|
1932
|
+
if (!current)
|
|
1933
|
+
throw new NotFoundException('Workflow instance not found');
|
|
1934
|
+
return workflowExecutionResult(current);
|
|
1935
|
+
}
|
|
1936
|
+
instance = { ...instance, temporalRunId };
|
|
1937
|
+
launchReservedByCaller = true;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (!launchReservedByCaller) return workflowExecutionResult(instance);
|
|
1941
|
+
|
|
1942
|
+
try {
|
|
1943
|
+
await temporalService.startWorkflow({
|
|
1944
|
+
instanceId: Number(instance.id),
|
|
1945
|
+
workflowId: Number(workflowId),
|
|
1946
|
+
versionId: Number(instanceVersion.id),
|
|
1947
|
+
dsl: instanceVersion.dsl,
|
|
1948
|
+
context: instance.context as Record<string, any>,
|
|
1949
|
+
retryCheckpoint: readRetryCheckpoint(instance.context),
|
|
1950
|
+
});
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
await this.prisma.workflowInstance.updateMany({
|
|
1953
|
+
where: { id: instance.id, status: 'PENDING', temporalRunId },
|
|
1954
|
+
data: {
|
|
1955
|
+
status: 'FAILED',
|
|
1956
|
+
result: {
|
|
1957
|
+
code: 'TEMPORAL_START_FAILED',
|
|
1958
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1959
|
+
},
|
|
1960
|
+
finishedAt: new Date(),
|
|
1961
|
+
},
|
|
1962
|
+
});
|
|
1963
|
+
throw error;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const current = await this.prisma.workflowInstance.findUnique({
|
|
1967
|
+
where: { id: instance.id },
|
|
783
1968
|
});
|
|
1969
|
+
if (!current) throw new NotFoundException('Workflow instance not found');
|
|
1970
|
+
if (current.status === 'CANCELLED') {
|
|
1971
|
+
await temporalService
|
|
1972
|
+
.cancelWorkflow(temporalRunId)
|
|
1973
|
+
.catch((error) =>
|
|
1974
|
+
this.logger.warn(
|
|
1975
|
+
`Failed to cancel Temporal workflow ${temporalRunId} after launch race: ${
|
|
1976
|
+
error instanceof Error ? error.message : String(error)
|
|
1977
|
+
}`,
|
|
1978
|
+
),
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
return workflowExecutionResult(current);
|
|
1982
|
+
}
|
|
784
1983
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1984
|
+
async executeManualWorkflowByCode(
|
|
1985
|
+
code: string,
|
|
1986
|
+
workflowData: Record<string, unknown>,
|
|
1987
|
+
rawIdempotencyKey: string | undefined,
|
|
1988
|
+
creator: string,
|
|
1989
|
+
) {
|
|
1990
|
+
const idempotencyKey = scopedIdempotencyKey(
|
|
1991
|
+
'manual',
|
|
1992
|
+
normalizeIdempotencyKey(rawIdempotencyKey),
|
|
1993
|
+
);
|
|
1994
|
+
const workflow = await this.prisma.workflow.findUnique({
|
|
1995
|
+
where: { code: code.trim() },
|
|
1996
|
+
include: {
|
|
1997
|
+
versions: {
|
|
1998
|
+
orderBy: { version: 'desc' },
|
|
1999
|
+
take: 1,
|
|
2000
|
+
},
|
|
2001
|
+
},
|
|
792
2002
|
});
|
|
793
2003
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
2004
|
+
if (!workflow) throw new NotFoundException('Workflow not found');
|
|
2005
|
+
if (workflow.status !== 'PUBLISHED')
|
|
2006
|
+
throw new BadRequestException('Workflow is not published');
|
|
2007
|
+
if (!workflow.isEnabled)
|
|
2008
|
+
throw new BadRequestException('Workflow is disabled');
|
|
2009
|
+
|
|
2010
|
+
const version = workflow.versions[0];
|
|
2011
|
+
if (!version) throw new NotFoundException('No published version found');
|
|
2012
|
+
assertManualWorkflowExecution(version.dsl, workflowData);
|
|
799
2013
|
|
|
800
|
-
return
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
2014
|
+
return this.executeWorkflow(
|
|
2015
|
+
workflow.id,
|
|
2016
|
+
{
|
|
2017
|
+
workflow: workflowData,
|
|
2018
|
+
workflowVersionId: version.id,
|
|
2019
|
+
idempotencyKey,
|
|
2020
|
+
},
|
|
2021
|
+
creator,
|
|
2022
|
+
this.temporalService,
|
|
2023
|
+
);
|
|
806
2024
|
}
|
|
807
2025
|
|
|
808
2026
|
/**
|
|
@@ -823,18 +2041,15 @@ export class WorkflowService {
|
|
|
823
2041
|
body: any;
|
|
824
2042
|
headers: Record<string, any>;
|
|
825
2043
|
query: Record<string, any>;
|
|
826
|
-
}): Promise<{
|
|
827
|
-
instanceIds: string[];
|
|
828
|
-
response?: {
|
|
829
|
-
statusCode: number;
|
|
830
|
-
headers: Record<string, string>;
|
|
831
|
-
body: unknown;
|
|
832
|
-
};
|
|
833
|
-
}> {
|
|
2044
|
+
}): Promise<{ instanceIds: string[] }> {
|
|
834
2045
|
// 1) Lookup matching published + enabled workflows. Verification is configured
|
|
835
2046
|
// per trigger, so the matching trigger must be known before headers are checked.
|
|
836
2047
|
const published = await this.prisma.workflow.findMany({
|
|
837
|
-
where: {
|
|
2048
|
+
where: {
|
|
2049
|
+
status: 'PUBLISHED',
|
|
2050
|
+
isEnabled: true,
|
|
2051
|
+
versions: { some: {} },
|
|
2052
|
+
},
|
|
838
2053
|
include: {
|
|
839
2054
|
versions: { orderBy: { version: 'desc' }, take: 1 },
|
|
840
2055
|
},
|
|
@@ -846,8 +2061,6 @@ export class WorkflowService {
|
|
|
846
2061
|
dsl: any;
|
|
847
2062
|
secret?: string;
|
|
848
2063
|
verificationMode: 'HMAC' | 'NONE';
|
|
849
|
-
responseMode: 'IMMEDIATE' | 'WAIT_FOR_RESULT';
|
|
850
|
-
responseTimeoutSeconds: number;
|
|
851
2064
|
};
|
|
852
2065
|
const matches: Match[] = [];
|
|
853
2066
|
for (const wf of published) {
|
|
@@ -866,8 +2079,6 @@ export class WorkflowService {
|
|
|
866
2079
|
dsl,
|
|
867
2080
|
secret: cfg.secret,
|
|
868
2081
|
verificationMode: cfg.verificationMode,
|
|
869
|
-
responseMode: cfg.responseMode,
|
|
870
|
-
responseTimeoutSeconds: cfg.responseTimeoutSeconds,
|
|
871
2082
|
});
|
|
872
2083
|
}
|
|
873
2084
|
|
|
@@ -906,6 +2117,15 @@ export class WorkflowService {
|
|
|
906
2117
|
}
|
|
907
2118
|
}
|
|
908
2119
|
|
|
2120
|
+
const webhookIdempotencyKey = scopedIdempotencyKey(
|
|
2121
|
+
'webhook',
|
|
2122
|
+
normalizeIdempotencyKey(
|
|
2123
|
+
typeof input.headers['idempotency-key'] === 'string'
|
|
2124
|
+
? input.headers['idempotency-key']
|
|
2125
|
+
: undefined,
|
|
2126
|
+
),
|
|
2127
|
+
);
|
|
2128
|
+
|
|
909
2129
|
// 3) Trigger executions in parallel
|
|
910
2130
|
const workflowData = {
|
|
911
2131
|
headers: input.headers,
|
|
@@ -917,78 +2137,27 @@ export class WorkflowService {
|
|
|
917
2137
|
matches.map((m) =>
|
|
918
2138
|
this.executeWorkflow(
|
|
919
2139
|
m.workflowId,
|
|
920
|
-
{
|
|
2140
|
+
{
|
|
2141
|
+
workflow: workflowData,
|
|
2142
|
+
workflowVersionId: m.versionId,
|
|
2143
|
+
idempotencyKey: webhookIdempotencyKey,
|
|
2144
|
+
idempotencyIgnoredWorkflowFields: [
|
|
2145
|
+
'receivedAt',
|
|
2146
|
+
'headers.x-workflow-signature',
|
|
2147
|
+
'headers.x-workflow-timestamp',
|
|
2148
|
+
'headers.content-length',
|
|
2149
|
+
],
|
|
2150
|
+
},
|
|
921
2151
|
'webhook',
|
|
922
2152
|
this.temporalService,
|
|
923
2153
|
),
|
|
924
2154
|
),
|
|
925
2155
|
);
|
|
926
2156
|
|
|
927
|
-
|
|
928
|
-
const waitingIndex = matches.findIndex(
|
|
929
|
-
(match) => match.responseMode === 'WAIT_FOR_RESULT',
|
|
930
|
-
);
|
|
931
|
-
if (waitingIndex === -1) return { instanceIds };
|
|
932
|
-
|
|
933
|
-
const match = matches[waitingIndex];
|
|
934
|
-
const execution = results[waitingIndex];
|
|
935
|
-
try {
|
|
936
|
-
const waited = await this.temporalService.waitForWorkflowResult(
|
|
937
|
-
execution.temporalRunId,
|
|
938
|
-
match.responseTimeoutSeconds * 1000,
|
|
939
|
-
);
|
|
940
|
-
if (waited.kind === 'TIMED_OUT') {
|
|
941
|
-
return {
|
|
942
|
-
instanceIds,
|
|
943
|
-
response: {
|
|
944
|
-
statusCode: 504,
|
|
945
|
-
headers: {},
|
|
946
|
-
body: {
|
|
947
|
-
code: 'WORKFLOW_RESPONSE_TIMEOUT',
|
|
948
|
-
message: `Workflow did not return a result within ${match.responseTimeoutSeconds} seconds`,
|
|
949
|
-
instanceId: execution.instanceId,
|
|
950
|
-
},
|
|
951
|
-
},
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
const workflowResult = asRecord(waited.result);
|
|
956
|
-
if (
|
|
957
|
-
workflowResult.status !== 'COMPLETED' ||
|
|
958
|
-
!Object.prototype.hasOwnProperty.call(workflowResult, 'output')
|
|
959
|
-
) {
|
|
960
|
-
throw new Error('Workflow completed without a return result');
|
|
961
|
-
}
|
|
962
|
-
const httpResponse = asRecord(workflowResult.httpResponse);
|
|
963
|
-
return {
|
|
964
|
-
instanceIds,
|
|
965
|
-
response: {
|
|
966
|
-
statusCode: normalizeHttpStatusCode(httpResponse.statusCode),
|
|
967
|
-
headers: normalizeHttpHeaders(httpResponse.headers),
|
|
968
|
-
body: workflowResult.output,
|
|
969
|
-
},
|
|
970
|
-
};
|
|
971
|
-
} catch (error) {
|
|
972
|
-
this.logger.error(
|
|
973
|
-
`Workflow ${execution.instanceId} failed while waiting for an HTTP response`,
|
|
974
|
-
error instanceof Error ? error.stack : String(error),
|
|
975
|
-
);
|
|
976
|
-
return {
|
|
977
|
-
instanceIds,
|
|
978
|
-
response: {
|
|
979
|
-
statusCode: 500,
|
|
980
|
-
headers: {},
|
|
981
|
-
body: {
|
|
982
|
-
code: 'WORKFLOW_EXECUTION_FAILED',
|
|
983
|
-
message: 'Workflow execution failed',
|
|
984
|
-
instanceId: execution.instanceId,
|
|
985
|
-
},
|
|
986
|
-
},
|
|
987
|
-
};
|
|
988
|
-
}
|
|
2157
|
+
return { instanceIds: results.map((result) => result.instanceId) };
|
|
989
2158
|
}
|
|
990
2159
|
|
|
991
|
-
async getInstanceDetail(instanceId: bigint) {
|
|
2160
|
+
async getInstanceDetail(instanceId: bigint, userId: string) {
|
|
992
2161
|
const instance = await this.prisma.workflowInstance.findUnique({
|
|
993
2162
|
where: { id: instanceId },
|
|
994
2163
|
include: {
|
|
@@ -1001,6 +2170,29 @@ export class WorkflowService {
|
|
|
1001
2170
|
|
|
1002
2171
|
if (!instance) throw new NotFoundException('Instance not found');
|
|
1003
2172
|
|
|
2173
|
+
const [approvableRequests, retryAttempts] = await Promise.all([
|
|
2174
|
+
this.prisma.approvalRequest.findMany({
|
|
2175
|
+
where: {
|
|
2176
|
+
workflowInstanceId: instanceId,
|
|
2177
|
+
status: 'PENDING',
|
|
2178
|
+
candidates: {
|
|
2179
|
+
some: { principalRef: userId, invalidatedAt: null },
|
|
2180
|
+
},
|
|
2181
|
+
},
|
|
2182
|
+
select: { nodeId: true },
|
|
2183
|
+
}),
|
|
2184
|
+
this.prisma.workflowInstance.findMany({
|
|
2185
|
+
where: { retryOfInstanceId: instanceId },
|
|
2186
|
+
orderBy: { createdAt: 'desc' },
|
|
2187
|
+
select: {
|
|
2188
|
+
id: true,
|
|
2189
|
+
status: true,
|
|
2190
|
+
retryFromNodeId: true,
|
|
2191
|
+
createdAt: true,
|
|
2192
|
+
},
|
|
2193
|
+
}),
|
|
2194
|
+
]);
|
|
2195
|
+
|
|
1004
2196
|
// Fetch the workflow DSL from the version
|
|
1005
2197
|
let dsl: any = null;
|
|
1006
2198
|
if (instance.versionId) {
|
|
@@ -1014,30 +2206,439 @@ export class WorkflowService {
|
|
|
1014
2206
|
return {
|
|
1015
2207
|
...instance,
|
|
1016
2208
|
workflow: { name: instance.workflow.name },
|
|
2209
|
+
approvableNodeIds: approvableRequests.map((request) => request.nodeId),
|
|
2210
|
+
retryableNodeIds:
|
|
2211
|
+
instance.status === 'FAILED' && dsl && retryAttempts.length === 0
|
|
2212
|
+
? listRetryableNodeIds(
|
|
2213
|
+
dsl,
|
|
2214
|
+
instance.nodeExecutions,
|
|
2215
|
+
readRetryCheckpoint(instance.context),
|
|
2216
|
+
)
|
|
2217
|
+
: [],
|
|
2218
|
+
retryAttempts,
|
|
2219
|
+
reusedNodeIds:
|
|
2220
|
+
readRetryCheckpoint(instance.context)?.completedNodeIds ?? [],
|
|
1017
2221
|
dsl,
|
|
1018
2222
|
};
|
|
1019
2223
|
}
|
|
1020
2224
|
|
|
1021
|
-
async
|
|
2225
|
+
async getExecutionStatus(instanceId: bigint) {
|
|
1022
2226
|
const instance = await this.prisma.workflowInstance.findUnique({
|
|
1023
2227
|
where: { id: instanceId },
|
|
2228
|
+
select: {
|
|
2229
|
+
id: true,
|
|
2230
|
+
status: true,
|
|
2231
|
+
startedAt: true,
|
|
2232
|
+
finishedAt: true,
|
|
2233
|
+
result: true,
|
|
2234
|
+
},
|
|
1024
2235
|
});
|
|
2236
|
+
if (!instance) throw new NotFoundException('Workflow instance not found');
|
|
2237
|
+
const result = asRecord(instance.result);
|
|
2238
|
+
return {
|
|
2239
|
+
instanceId: instance.id.toString(),
|
|
2240
|
+
status: instance.status,
|
|
2241
|
+
startedAt: instance.startedAt,
|
|
2242
|
+
finishedAt: instance.finishedAt,
|
|
2243
|
+
...(Object.prototype.hasOwnProperty.call(result, 'output') && {
|
|
2244
|
+
result: result.output,
|
|
2245
|
+
}),
|
|
2246
|
+
...(instance.status === 'FAILED' && {
|
|
2247
|
+
error: {
|
|
2248
|
+
...(typeof result.code === 'string' ? { code: result.code } : {}),
|
|
2249
|
+
message:
|
|
2250
|
+
typeof result.message === 'string'
|
|
2251
|
+
? result.message
|
|
2252
|
+
: typeof result.error === 'string'
|
|
2253
|
+
? result.error
|
|
2254
|
+
: 'Workflow execution failed',
|
|
2255
|
+
},
|
|
2256
|
+
}),
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
1025
2259
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
2260
|
+
async cancelExecutionInTransaction(
|
|
2261
|
+
tx: Prisma.TransactionClient,
|
|
2262
|
+
instanceId: bigint,
|
|
2263
|
+
reason?: string,
|
|
2264
|
+
) {
|
|
2265
|
+
const rows = await tx.$queryRaw<
|
|
2266
|
+
Array<{
|
|
2267
|
+
id: bigint;
|
|
2268
|
+
status: string;
|
|
2269
|
+
temporalRunId: string | null;
|
|
2270
|
+
cancellationBlockedAt: Date | null;
|
|
2271
|
+
}>
|
|
2272
|
+
>`SELECT id, status::text AS status,
|
|
2273
|
+
temporal_run_id AS "temporalRunId",
|
|
2274
|
+
cancellation_blocked_at AS "cancellationBlockedAt"
|
|
2275
|
+
FROM t_workflow_instance
|
|
2276
|
+
WHERE id = ${instanceId}
|
|
2277
|
+
FOR UPDATE`;
|
|
2278
|
+
const instance = rows[0];
|
|
2279
|
+
if (!instance) throw new NotFoundException('Workflow instance not found');
|
|
2280
|
+
if (
|
|
2281
|
+
instance.status !== 'CANCELLED' &&
|
|
2282
|
+
!['RUNNING', 'PENDING'].includes(instance.status)
|
|
2283
|
+
)
|
|
2284
|
+
throw new ConflictException(
|
|
2285
|
+
'Only RUNNING or PENDING instances can be cancelled',
|
|
2286
|
+
);
|
|
2287
|
+
if (
|
|
2288
|
+
instance.status !== 'CANCELLED' &&
|
|
2289
|
+
instance.cancellationBlockedAt != null
|
|
2290
|
+
)
|
|
2291
|
+
throw new ConflictException({
|
|
2292
|
+
code: 'WORKFLOW_CANCELLATION_BLOCKED',
|
|
2293
|
+
message: 'Workflow cancellation is blocked by a resolved approval',
|
|
2294
|
+
});
|
|
2295
|
+
|
|
2296
|
+
const now = new Date();
|
|
2297
|
+
if (instance.status !== 'CANCELLED') {
|
|
2298
|
+
await tx.workflowInstance.update({
|
|
2299
|
+
where: { id: instanceId },
|
|
2300
|
+
data: {
|
|
2301
|
+
status: 'CANCELLED',
|
|
2302
|
+
finishedAt: now,
|
|
2303
|
+
cancellationRequestedAt: now,
|
|
2304
|
+
cancellationReason: reason?.trim().slice(0, 512) || null,
|
|
2305
|
+
cancellationStatus: instance.temporalRunId ? 'PENDING' : 'COMPLETED',
|
|
2306
|
+
cancellationLastError: null,
|
|
2307
|
+
},
|
|
2308
|
+
});
|
|
2309
|
+
await Promise.all([
|
|
2310
|
+
tx.approvalRequest.updateMany({
|
|
2311
|
+
where: {
|
|
2312
|
+
workflowInstanceId: instanceId,
|
|
2313
|
+
status: { in: ['PENDING', 'BLOCKED'] },
|
|
2314
|
+
},
|
|
2315
|
+
data: { status: 'CANCELLED', completedAt: now },
|
|
2316
|
+
}),
|
|
2317
|
+
tx.externalInteraction.updateMany({
|
|
2318
|
+
where: { workflowInstanceId: instanceId, status: 'PENDING' },
|
|
2319
|
+
data: { status: 'CANCELLED', resolvedAt: now },
|
|
2320
|
+
}),
|
|
2321
|
+
]);
|
|
1029
2322
|
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
async findEventTriggeredExecutionInTransaction(
|
|
2326
|
+
tx: Prisma.TransactionClient,
|
|
2327
|
+
input: {
|
|
2328
|
+
workflowCode: string;
|
|
2329
|
+
tableName: string;
|
|
2330
|
+
operation: 'INSERT' | 'UPDATE' | 'DELETE';
|
|
2331
|
+
primaryKey: Record<string, string | number | boolean>;
|
|
2332
|
+
},
|
|
2333
|
+
): Promise<{ instanceId: bigint; status: string } | null> {
|
|
2334
|
+
const rows = await tx.$queryRaw<
|
|
2335
|
+
Array<{ instanceId: bigint; status: string }>
|
|
2336
|
+
>`
|
|
2337
|
+
WITH RECURSIVE execution_tree AS (
|
|
2338
|
+
SELECT instance.id AS "instanceId",
|
|
2339
|
+
instance.status::text AS status,
|
|
2340
|
+
instance.created_at AS "createdAt",
|
|
2341
|
+
0 AS depth
|
|
2342
|
+
FROM t_workflow_event_outbox outbox
|
|
2343
|
+
JOIN t_workflow_event_delivery delivery
|
|
2344
|
+
ON delivery.outbox_id = outbox.id
|
|
2345
|
+
JOIN t_workflow_instance instance
|
|
2346
|
+
ON instance.id = delivery.workflow_instance_id
|
|
2347
|
+
JOIN t_workflow workflow
|
|
2348
|
+
ON workflow.id = delivery.workflow_id
|
|
2349
|
+
WHERE outbox.schema_name = current_schema()
|
|
2350
|
+
AND outbox.table_name = ${input.tableName}
|
|
2351
|
+
AND outbox.operation::text = ${input.operation}
|
|
2352
|
+
AND outbox.primary_key = ${JSON.stringify(input.primaryKey)}::jsonb
|
|
2353
|
+
AND workflow.code = ${input.workflowCode}
|
|
2354
|
+
|
|
2355
|
+
UNION ALL
|
|
2356
|
+
|
|
2357
|
+
SELECT retry.id AS "instanceId",
|
|
2358
|
+
retry.status::text AS status,
|
|
2359
|
+
retry.created_at AS "createdAt",
|
|
2360
|
+
parent.depth + 1 AS depth
|
|
2361
|
+
FROM t_workflow_instance retry
|
|
2362
|
+
JOIN execution_tree parent
|
|
2363
|
+
ON retry.retry_of_instance_id = parent."instanceId"
|
|
2364
|
+
)
|
|
2365
|
+
SELECT "instanceId", status
|
|
2366
|
+
FROM execution_tree
|
|
2367
|
+
ORDER BY depth DESC, "createdAt" DESC, "instanceId" DESC
|
|
2368
|
+
LIMIT 1
|
|
2369
|
+
`;
|
|
2370
|
+
return rows[0] ?? null;
|
|
2371
|
+
}
|
|
1030
2372
|
|
|
1031
|
-
|
|
1032
|
-
|
|
2373
|
+
private async deliverTemporalCancellation(
|
|
2374
|
+
instanceId: bigint,
|
|
2375
|
+
temporalRunId: string,
|
|
2376
|
+
) {
|
|
2377
|
+
try {
|
|
2378
|
+
await this.temporalService.cancelWorkflow(temporalRunId);
|
|
2379
|
+
await this.prisma.workflowInstance.updateMany({
|
|
2380
|
+
where: { id: instanceId, cancellationStatus: 'PENDING' },
|
|
2381
|
+
data: {
|
|
2382
|
+
cancellationStatus: 'COMPLETED',
|
|
2383
|
+
cancellationLastError: null,
|
|
2384
|
+
},
|
|
2385
|
+
});
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2388
|
+
await this.prisma.workflowInstance.updateMany({
|
|
2389
|
+
where: { id: instanceId, cancellationStatus: 'PENDING' },
|
|
2390
|
+
data: { cancellationLastError: message },
|
|
2391
|
+
});
|
|
2392
|
+
this.logger.warn(
|
|
2393
|
+
`Temporal cancellation pending for Workflow instance ${instanceId}: ${message}`,
|
|
2394
|
+
);
|
|
1033
2395
|
}
|
|
2396
|
+
}
|
|
1034
2397
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
2398
|
+
@Interval(30_000)
|
|
2399
|
+
async reconcilePendingCancellations() {
|
|
2400
|
+
const pending = await this.prisma.workflowInstance.findMany({
|
|
2401
|
+
where: {
|
|
2402
|
+
status: 'CANCELLED',
|
|
2403
|
+
cancellationStatus: 'PENDING',
|
|
2404
|
+
temporalRunId: { not: null },
|
|
2405
|
+
},
|
|
2406
|
+
orderBy: { cancellationRequestedAt: 'asc' },
|
|
2407
|
+
take: 50,
|
|
2408
|
+
select: { id: true, temporalRunId: true },
|
|
2409
|
+
});
|
|
2410
|
+
await Promise.all(
|
|
2411
|
+
pending.map((instance) =>
|
|
2412
|
+
this.deliverTemporalCancellation(instance.id, instance.temporalRunId!),
|
|
2413
|
+
),
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
@Interval(60_000)
|
|
2418
|
+
async reconcileUnstartedInstances(): Promise<void> {
|
|
2419
|
+
const cutoff = new Date(Date.now() - 60_000);
|
|
2420
|
+
await this.prisma.workflowInstance.updateMany({
|
|
2421
|
+
where: {
|
|
2422
|
+
status: 'PENDING',
|
|
2423
|
+
temporalRunId: null,
|
|
2424
|
+
createdAt: { lte: cutoff },
|
|
2425
|
+
},
|
|
2426
|
+
data: {
|
|
2427
|
+
status: 'FAILED',
|
|
2428
|
+
result: {
|
|
2429
|
+
code: 'TEMPORAL_START_NOT_RESERVED',
|
|
2430
|
+
message: 'Workflow failed before Temporal execution was reserved',
|
|
2431
|
+
},
|
|
2432
|
+
finishedAt: new Date(),
|
|
2433
|
+
},
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
const reservedInstances = await this.prisma.workflowInstance.findMany({
|
|
2437
|
+
where: {
|
|
2438
|
+
status: { in: ['PENDING', 'RUNNING'] },
|
|
2439
|
+
temporalRunId: { not: null },
|
|
2440
|
+
updatedAt: { lte: cutoff },
|
|
2441
|
+
},
|
|
2442
|
+
select: {
|
|
2443
|
+
id: true,
|
|
2444
|
+
workflowId: true,
|
|
2445
|
+
versionId: true,
|
|
2446
|
+
temporalRunId: true,
|
|
2447
|
+
context: true,
|
|
2448
|
+
status: true,
|
|
2449
|
+
},
|
|
2450
|
+
});
|
|
2451
|
+
for (const instance of reservedInstances) {
|
|
2452
|
+
try {
|
|
2453
|
+
let temporalState = await this.temporalService.inspectWorkflow(
|
|
2454
|
+
instance.temporalRunId!,
|
|
2455
|
+
);
|
|
2456
|
+
if (temporalState.kind === 'MISSING') {
|
|
2457
|
+
if (instance.status === 'RUNNING') {
|
|
2458
|
+
await this.failMissingRunningInstance(
|
|
2459
|
+
instance.id,
|
|
2460
|
+
instance.temporalRunId!,
|
|
2461
|
+
);
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
const version = await this.prisma.workflowVersion.findUnique({
|
|
2465
|
+
where: { id: instance.versionId },
|
|
2466
|
+
select: { dsl: true },
|
|
2467
|
+
});
|
|
2468
|
+
if (!version) {
|
|
2469
|
+
await this.failUnstartedInstance(
|
|
2470
|
+
instance.id,
|
|
2471
|
+
instance.temporalRunId!,
|
|
2472
|
+
'Workflow version not found during launch reconciliation',
|
|
2473
|
+
);
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
await this.temporalService.startWorkflow({
|
|
2477
|
+
instanceId: Number(instance.id),
|
|
2478
|
+
workflowId: Number(instance.workflowId),
|
|
2479
|
+
versionId: Number(instance.versionId),
|
|
2480
|
+
dsl: version.dsl,
|
|
2481
|
+
context: (instance.context ?? {}) as Record<string, any>,
|
|
2482
|
+
retryCheckpoint: readRetryCheckpoint(instance.context),
|
|
2483
|
+
});
|
|
2484
|
+
temporalState = await this.temporalService.inspectWorkflow(
|
|
2485
|
+
instance.temporalRunId!,
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
if (temporalState.kind === 'MISSING') continue;
|
|
2489
|
+
await this.reconcileInstanceFromTemporal(
|
|
2490
|
+
instance.id,
|
|
2491
|
+
instance.temporalRunId!,
|
|
2492
|
+
temporalState,
|
|
2493
|
+
);
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
this.logger.warn(
|
|
2496
|
+
`Failed to reconcile Workflow instance ${instance.id}: ${
|
|
2497
|
+
error instanceof Error ? error.message : String(error)
|
|
2498
|
+
}`,
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
private async failMissingRunningInstance(
|
|
2505
|
+
instanceId: bigint,
|
|
2506
|
+
temporalRunId: string,
|
|
2507
|
+
): Promise<void> {
|
|
2508
|
+
await this.prisma.workflowInstance.updateMany({
|
|
2509
|
+
where: { id: instanceId, status: 'RUNNING', temporalRunId },
|
|
2510
|
+
data: {
|
|
2511
|
+
status: 'FAILED',
|
|
2512
|
+
result: {
|
|
2513
|
+
code: 'TEMPORAL_EXECUTION_MISSING',
|
|
2514
|
+
message: 'Temporal execution disappeared while Workflow was running',
|
|
2515
|
+
},
|
|
2516
|
+
finishedAt: new Date(),
|
|
2517
|
+
},
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
private async failUnstartedInstance(
|
|
2522
|
+
instanceId: bigint,
|
|
2523
|
+
temporalRunId: string,
|
|
2524
|
+
message: string,
|
|
2525
|
+
): Promise<void> {
|
|
2526
|
+
await this.prisma.workflowInstance.updateMany({
|
|
2527
|
+
where: { id: instanceId, status: 'PENDING', temporalRunId },
|
|
2528
|
+
data: {
|
|
2529
|
+
status: 'FAILED',
|
|
2530
|
+
result: { code: 'TEMPORAL_START_RECONCILIATION_FAILED', message },
|
|
2531
|
+
finishedAt: new Date(),
|
|
2532
|
+
},
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
@Interval(60_000)
|
|
2537
|
+
async reconcileTerminalInteractions(): Promise<void> {
|
|
2538
|
+
const pendingApprovals = await this.prisma.$queryRaw<Array<{ id: bigint }>>`
|
|
2539
|
+
SELECT approval.id
|
|
2540
|
+
FROM t_approval_request approval
|
|
2541
|
+
JOIN t_workflow_instance instance
|
|
2542
|
+
ON instance.id = approval.workflow_instance_id
|
|
2543
|
+
WHERE approval.status = 'PENDING'
|
|
2544
|
+
AND instance.status IN ('COMPLETED', 'FAILED', 'CANCELLED')
|
|
2545
|
+
ORDER BY approval.id
|
|
2546
|
+
LIMIT 100`;
|
|
2547
|
+
const approvalResults = await Promise.allSettled(
|
|
2548
|
+
pendingApprovals.map(({ id }) =>
|
|
2549
|
+
this.humanApprovalService.cancelRequest(id, false),
|
|
2550
|
+
),
|
|
2551
|
+
);
|
|
2552
|
+
approvalResults.forEach((result, index) => {
|
|
2553
|
+
if (result.status === 'rejected')
|
|
2554
|
+
this.logger.warn(
|
|
2555
|
+
`Failed to reconcile approval ${pendingApprovals[index].id}: ${
|
|
2556
|
+
result.reason instanceof Error
|
|
2557
|
+
? result.reason.message
|
|
2558
|
+
: String(result.reason)
|
|
2559
|
+
}`,
|
|
2560
|
+
);
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
await this.prisma.externalInteraction.updateMany({
|
|
2564
|
+
where: {
|
|
2565
|
+
status: 'PENDING',
|
|
2566
|
+
workflowInstance: {
|
|
2567
|
+
status: { in: ['COMPLETED', 'FAILED', 'CANCELLED'] },
|
|
2568
|
+
},
|
|
2569
|
+
},
|
|
2570
|
+
data: { status: 'CANCELLED', resolvedAt: new Date() },
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
private async reconcileInstanceFromTemporal(
|
|
2575
|
+
instanceId: bigint,
|
|
2576
|
+
temporalRunId: string,
|
|
2577
|
+
state: Exclude<
|
|
2578
|
+
Awaited<ReturnType<TemporalService['inspectWorkflow']>>,
|
|
2579
|
+
{ kind: 'MISSING' }
|
|
2580
|
+
>,
|
|
2581
|
+
): Promise<void> {
|
|
2582
|
+
if (state.status === 'RUNNING' || state.status === 'PAUSED') {
|
|
2583
|
+
const updated = await this.prisma.workflowInstance.updateMany({
|
|
2584
|
+
where: { id: instanceId, status: 'PENDING', temporalRunId },
|
|
2585
|
+
data: { status: 'RUNNING', startedAt: state.startedAt },
|
|
2586
|
+
});
|
|
2587
|
+
if (updated.count !== 1) {
|
|
2588
|
+
const current = await this.prisma.workflowInstance.findUnique({
|
|
2589
|
+
where: { id: instanceId },
|
|
2590
|
+
select: { status: true },
|
|
2591
|
+
});
|
|
2592
|
+
if (current?.status === 'CANCELLED')
|
|
2593
|
+
await this.temporalService.cancelWorkflow(temporalRunId);
|
|
2594
|
+
}
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
const status =
|
|
2598
|
+
state.status === 'COMPLETED'
|
|
2599
|
+
? 'COMPLETED'
|
|
2600
|
+
: state.status === 'CANCELLED'
|
|
2601
|
+
? 'CANCELLED'
|
|
2602
|
+
: 'FAILED';
|
|
2603
|
+
await this.prisma.workflowInstance.updateMany({
|
|
2604
|
+
where: {
|
|
2605
|
+
id: instanceId,
|
|
2606
|
+
status: { in: ['PENDING', 'RUNNING'] },
|
|
2607
|
+
temporalRunId,
|
|
2608
|
+
},
|
|
2609
|
+
data: {
|
|
2610
|
+
status,
|
|
2611
|
+
result:
|
|
2612
|
+
state.result === undefined
|
|
2613
|
+
? {
|
|
2614
|
+
code: 'TEMPORAL_TERMINAL_STATUS_RECONCILED',
|
|
2615
|
+
temporalStatus: state.status,
|
|
2616
|
+
}
|
|
2617
|
+
: normalizeTemporalCompletedResult(state.result),
|
|
2618
|
+
startedAt: state.startedAt,
|
|
2619
|
+
finishedAt: state.finishedAt ?? new Date(),
|
|
2620
|
+
},
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
@Interval(60_000)
|
|
2625
|
+
async reconcileCronSchedules(): Promise<void> {
|
|
2626
|
+
const workflows = await this.prisma.workflow.findMany({
|
|
2627
|
+
where: { status: 'PUBLISHED' },
|
|
2628
|
+
select: { id: true },
|
|
1038
2629
|
});
|
|
1039
2630
|
|
|
1040
|
-
|
|
2631
|
+
for (const workflow of workflows) {
|
|
2632
|
+
try {
|
|
2633
|
+
await this.reconcileCronScheduleCurrent(workflow.id);
|
|
2634
|
+
} catch (error) {
|
|
2635
|
+
this.logger.warn(
|
|
2636
|
+
`Failed to reconcile cron schedule for workflow ${workflow.id}: ${
|
|
2637
|
+
error instanceof Error ? error.message : String(error)
|
|
2638
|
+
}`,
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
1041
2642
|
}
|
|
1042
2643
|
|
|
1043
2644
|
async retryInstance(
|
|
@@ -1054,7 +2655,15 @@ export class WorkflowService {
|
|
|
1054
2655
|
throw new Error('Can only retry FAILED instances');
|
|
1055
2656
|
}
|
|
1056
2657
|
|
|
2658
|
+
const existingRetry = await this.prisma.workflowInstance.findFirst({
|
|
2659
|
+
where: { retryOfInstanceId: instance.id },
|
|
2660
|
+
orderBy: { createdAt: 'desc' },
|
|
2661
|
+
});
|
|
2662
|
+
if (existingRetry) return workflowExecutionResult(existingRetry);
|
|
2663
|
+
|
|
1057
2664
|
const executionState = instance.context as Record<string, any>;
|
|
2665
|
+
const operationScopeId =
|
|
2666
|
+
executionState.system?.operationScopeId ?? String(instance.id);
|
|
1058
2667
|
// Create a new instance with the same immutable Workflow data.
|
|
1059
2668
|
return this.executeWorkflow(
|
|
1060
2669
|
instance.workflowId,
|
|
@@ -1066,32 +2675,70 @@ export class WorkflowService {
|
|
|
1066
2675
|
? executionState.workflow
|
|
1067
2676
|
: {},
|
|
1068
2677
|
workflowVersionId: instance.versionId,
|
|
2678
|
+
idempotencyKey: `retry:${instance.id}`,
|
|
1069
2679
|
},
|
|
1070
2680
|
creator,
|
|
1071
2681
|
temporalService,
|
|
2682
|
+
{
|
|
2683
|
+
operationScopeId: String(operationScopeId),
|
|
2684
|
+
retryOfInstanceId: instance.id,
|
|
2685
|
+
},
|
|
1072
2686
|
);
|
|
1073
2687
|
}
|
|
1074
2688
|
|
|
1075
|
-
async
|
|
2689
|
+
async retryInstanceFromNode(
|
|
1076
2690
|
instanceId: bigint,
|
|
1077
|
-
|
|
1078
|
-
|
|
2691
|
+
nodeId: string,
|
|
2692
|
+
creator: string,
|
|
2693
|
+
temporalService: TemporalService,
|
|
1079
2694
|
) {
|
|
1080
2695
|
const instance = await this.prisma.workflowInstance.findUnique({
|
|
1081
2696
|
where: { id: instanceId },
|
|
2697
|
+
include: { nodeExecutions: true },
|
|
1082
2698
|
});
|
|
1083
|
-
|
|
1084
2699
|
if (!instance) throw new NotFoundException('Instance not found');
|
|
1085
|
-
if (
|
|
1086
|
-
throw new
|
|
2700
|
+
if (instance.status !== 'FAILED') {
|
|
2701
|
+
throw new BadRequestException('只有失败的 Workflow 才能从节点重试');
|
|
1087
2702
|
}
|
|
1088
2703
|
|
|
1089
|
-
await
|
|
1090
|
-
instance.
|
|
1091
|
-
|
|
1092
|
-
|
|
2704
|
+
const existingRetry = await this.prisma.workflowInstance.findFirst({
|
|
2705
|
+
where: { retryOfInstanceId: instance.id },
|
|
2706
|
+
orderBy: { createdAt: 'desc' },
|
|
2707
|
+
});
|
|
2708
|
+
if (existingRetry) return workflowExecutionResult(existingRetry);
|
|
2709
|
+
|
|
2710
|
+
const version = await this.prisma.workflowVersion.findUnique({
|
|
2711
|
+
where: { id: instance.versionId },
|
|
2712
|
+
select: { dsl: true },
|
|
2713
|
+
});
|
|
2714
|
+
if (!version) throw new NotFoundException('Workflow version not found');
|
|
2715
|
+
|
|
2716
|
+
const retryCheckpoint = buildRetryCheckpoint(
|
|
2717
|
+
version.dsl,
|
|
2718
|
+
instance.nodeExecutions,
|
|
2719
|
+
nodeId,
|
|
2720
|
+
readRetryCheckpoint(instance.context),
|
|
2721
|
+
);
|
|
2722
|
+
const executionState = asRecord(instance.context);
|
|
2723
|
+
const operationScopeId =
|
|
2724
|
+
asRecord(executionState.system).operationScopeId ?? String(instance.id);
|
|
2725
|
+
|
|
2726
|
+
return this.executeWorkflow(
|
|
2727
|
+
instance.workflowId,
|
|
2728
|
+
{
|
|
2729
|
+
workflow: asRecord(executionState.workflow),
|
|
2730
|
+
workflowVersionId: instance.versionId,
|
|
2731
|
+
idempotencyKey: `retry:${instance.id}`,
|
|
2732
|
+
},
|
|
2733
|
+
creator,
|
|
2734
|
+
temporalService,
|
|
2735
|
+
{
|
|
2736
|
+
operationScopeId: String(operationScopeId),
|
|
2737
|
+
retryCheckpoint,
|
|
2738
|
+
retryOfInstanceId: instance.id,
|
|
2739
|
+
retryFromNodeId: nodeId,
|
|
2740
|
+
},
|
|
1093
2741
|
);
|
|
1094
|
-
return { success: true };
|
|
1095
2742
|
}
|
|
1096
2743
|
|
|
1097
2744
|
async approveInstance(
|
|
@@ -1121,10 +2768,32 @@ export class WorkflowService {
|
|
|
1121
2768
|
|
|
1122
2769
|
function createWorkflowExecutionState(
|
|
1123
2770
|
value: Record<string, any> | undefined,
|
|
2771
|
+
operationScopeId?: string,
|
|
2772
|
+
retryCheckpoint?: WorkflowRetryCheckpoint,
|
|
1124
2773
|
): Record<string, any> {
|
|
1125
2774
|
return {
|
|
1126
2775
|
workflow: value ?? {},
|
|
1127
2776
|
nodes: {},
|
|
2777
|
+
...((operationScopeId || retryCheckpoint) && {
|
|
2778
|
+
system: {
|
|
2779
|
+
...(operationScopeId && { operationScopeId }),
|
|
2780
|
+
...(retryCheckpoint && { retryCheckpoint }),
|
|
2781
|
+
},
|
|
2782
|
+
}),
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
function workflowExecutionResult(instance: {
|
|
2787
|
+
id: bigint;
|
|
2788
|
+
versionId: bigint;
|
|
2789
|
+
status: string;
|
|
2790
|
+
temporalRunId: string | null;
|
|
2791
|
+
}) {
|
|
2792
|
+
return {
|
|
2793
|
+
instanceId: instance.id.toString(),
|
|
2794
|
+
temporalRunId: instance.temporalRunId,
|
|
2795
|
+
workflowVersionId: instance.versionId.toString(),
|
|
2796
|
+
status: instance.status,
|
|
1128
2797
|
};
|
|
1129
2798
|
}
|
|
1130
2799
|
|
|
@@ -1134,6 +2803,190 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
1134
2803
|
: {};
|
|
1135
2804
|
}
|
|
1136
2805
|
|
|
2806
|
+
function quoteIdentifier(value: string): string {
|
|
2807
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function quoteLiteral(value: string): string {
|
|
2811
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function readRetryCheckpoint(
|
|
2815
|
+
value: unknown,
|
|
2816
|
+
): WorkflowRetryCheckpoint | undefined {
|
|
2817
|
+
const checkpoint = asRecord(asRecord(value).system).retryCheckpoint;
|
|
2818
|
+
if (
|
|
2819
|
+
!checkpoint ||
|
|
2820
|
+
typeof checkpoint !== 'object' ||
|
|
2821
|
+
Array.isArray(checkpoint)
|
|
2822
|
+
)
|
|
2823
|
+
return undefined;
|
|
2824
|
+
const record = checkpoint as Record<string, unknown>;
|
|
2825
|
+
if (
|
|
2826
|
+
typeof record.startNodeId !== 'string' ||
|
|
2827
|
+
!Array.isArray(record.completedNodeIds) ||
|
|
2828
|
+
!record.completedNodeIds.every((item) => typeof item === 'string') ||
|
|
2829
|
+
!record.outputs ||
|
|
2830
|
+
typeof record.outputs !== 'object' ||
|
|
2831
|
+
Array.isArray(record.outputs)
|
|
2832
|
+
)
|
|
2833
|
+
return undefined;
|
|
2834
|
+
const outputs = record.outputs as Record<string, unknown>;
|
|
2835
|
+
if (
|
|
2836
|
+
!record.completedNodeIds.every((nodeId) =>
|
|
2837
|
+
Object.prototype.hasOwnProperty.call(outputs, nodeId),
|
|
2838
|
+
)
|
|
2839
|
+
)
|
|
2840
|
+
return undefined;
|
|
2841
|
+
return {
|
|
2842
|
+
startNodeId: record.startNodeId,
|
|
2843
|
+
completedNodeIds: record.completedNodeIds,
|
|
2844
|
+
outputs,
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
function buildRetryCheckpoint(
|
|
2849
|
+
dslValue: unknown,
|
|
2850
|
+
nodeExecutions: Array<{
|
|
2851
|
+
nodeId: string;
|
|
2852
|
+
status: string;
|
|
2853
|
+
output: unknown;
|
|
2854
|
+
}>,
|
|
2855
|
+
retryFromNodeId: string,
|
|
2856
|
+
inheritedCheckpoint?: WorkflowRetryCheckpoint,
|
|
2857
|
+
): WorkflowRetryCheckpoint {
|
|
2858
|
+
const dsl = asRecord(dslValue);
|
|
2859
|
+
const nodes = Array.isArray(dsl.nodes)
|
|
2860
|
+
? (dsl.nodes as Array<Record<string, unknown>>)
|
|
2861
|
+
: [];
|
|
2862
|
+
const edges = Array.isArray(dsl.edges)
|
|
2863
|
+
? (dsl.edges as Array<Record<string, unknown>>)
|
|
2864
|
+
: [];
|
|
2865
|
+
const target = nodes.find((node) => node.id === retryFromNodeId);
|
|
2866
|
+
if (!target) throw new BadRequestException('失败节点不在原发布版本中');
|
|
2867
|
+
if (!RETRYABLE_MAIN_FLOW_NODE_TYPES.has(String(target.type))) {
|
|
2868
|
+
throw new BadRequestException('该节点暂不支持从这里重试');
|
|
2869
|
+
}
|
|
2870
|
+
if (
|
|
2871
|
+
edges.some(
|
|
2872
|
+
(edge) =>
|
|
2873
|
+
typeof edge.pairId === 'string' &&
|
|
2874
|
+
edge.pairId.length > 0 &&
|
|
2875
|
+
(edge.source === retryFromNodeId || edge.target === retryFromNodeId),
|
|
2876
|
+
)
|
|
2877
|
+
) {
|
|
2878
|
+
throw new BadRequestException('暂不支持从循环或并行分支内部重试');
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const executionMap = new Map<
|
|
2882
|
+
string,
|
|
2883
|
+
{ nodeId: string; status: string; output: unknown }
|
|
2884
|
+
>();
|
|
2885
|
+
for (const completedNodeId of inheritedCheckpoint?.completedNodeIds ?? []) {
|
|
2886
|
+
executionMap.set(completedNodeId, {
|
|
2887
|
+
nodeId: completedNodeId,
|
|
2888
|
+
status: 'COMPLETED',
|
|
2889
|
+
output: inheritedCheckpoint?.outputs[completedNodeId],
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
for (const execution of nodeExecutions) {
|
|
2893
|
+
executionMap.set(execution.nodeId, execution);
|
|
2894
|
+
}
|
|
2895
|
+
if (executionMap.get(retryFromNodeId)?.status !== 'FAILED') {
|
|
2896
|
+
throw new BadRequestException('只能从实际失败的节点重试');
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
const mainPredecessors = new Map<string, string[]>();
|
|
2900
|
+
for (const edge of edges) {
|
|
2901
|
+
if (
|
|
2902
|
+
(edge.kind === undefined || edge.kind === 'FLOW') &&
|
|
2903
|
+
!edge.pairId &&
|
|
2904
|
+
typeof edge.source === 'string' &&
|
|
2905
|
+
typeof edge.target === 'string'
|
|
2906
|
+
) {
|
|
2907
|
+
const predecessors = mainPredecessors.get(edge.target) ?? [];
|
|
2908
|
+
predecessors.push(edge.source);
|
|
2909
|
+
mainPredecessors.set(edge.target, predecessors);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
const nodeMap = new Map(
|
|
2914
|
+
nodes
|
|
2915
|
+
.filter((node) => typeof node.id === 'string')
|
|
2916
|
+
.map((node) => [String(node.id), node]),
|
|
2917
|
+
);
|
|
2918
|
+
const ancestors = new Set<string>();
|
|
2919
|
+
const pending = [...(mainPredecessors.get(retryFromNodeId) ?? [])];
|
|
2920
|
+
while (pending.length > 0) {
|
|
2921
|
+
const current = pending.pop();
|
|
2922
|
+
if (!current) continue;
|
|
2923
|
+
if (ancestors.has(current)) continue;
|
|
2924
|
+
ancestors.add(current);
|
|
2925
|
+
pending.push(...(mainPredecessors.get(current) ?? []));
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
const completedNodeIds: string[] = [];
|
|
2929
|
+
const outputs: Record<string, unknown> = {};
|
|
2930
|
+
for (const node of nodes) {
|
|
2931
|
+
const ancestorId = typeof node.id === 'string' ? node.id : '';
|
|
2932
|
+
if (!ancestorId || !ancestors.has(ancestorId)) continue;
|
|
2933
|
+
if (String(node.type).endsWith('_trigger')) continue;
|
|
2934
|
+
const execution = executionMap.get(ancestorId);
|
|
2935
|
+
if (execution?.status !== 'COMPLETED') {
|
|
2936
|
+
throw new BadRequestException(
|
|
2937
|
+
`节点“${String(
|
|
2938
|
+
node.label || node.type || ancestorId,
|
|
2939
|
+
)}”没有可复用的成功结果`,
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
completedNodeIds.push(ancestorId);
|
|
2943
|
+
outputs[ancestorId] = execution.output;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
const immediatePredecessors = mainPredecessors.get(retryFromNodeId) ?? [];
|
|
2947
|
+
if (immediatePredecessors.length > 1) {
|
|
2948
|
+
throw new BadRequestException('失败节点存在多个上游,暂不支持从这里重试');
|
|
2949
|
+
}
|
|
2950
|
+
if (
|
|
2951
|
+
immediatePredecessors.length === 1 &&
|
|
2952
|
+
!String(nodeMap.get(immediatePredecessors[0])?.type ?? '').endsWith(
|
|
2953
|
+
'_trigger',
|
|
2954
|
+
) &&
|
|
2955
|
+
!Object.prototype.hasOwnProperty.call(outputs, immediatePredecessors[0])
|
|
2956
|
+
) {
|
|
2957
|
+
throw new BadRequestException('失败节点的上一步没有可复用结果');
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
return { startNodeId: retryFromNodeId, completedNodeIds, outputs };
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function listRetryableNodeIds(
|
|
2964
|
+
dsl: unknown,
|
|
2965
|
+
nodeExecutions: Array<{
|
|
2966
|
+
nodeId: string;
|
|
2967
|
+
status: string;
|
|
2968
|
+
output: unknown;
|
|
2969
|
+
}>,
|
|
2970
|
+
inheritedCheckpoint?: WorkflowRetryCheckpoint,
|
|
2971
|
+
): string[] {
|
|
2972
|
+
return nodeExecutions
|
|
2973
|
+
.filter((execution) => execution.status === 'FAILED')
|
|
2974
|
+
.filter((execution) => {
|
|
2975
|
+
try {
|
|
2976
|
+
buildRetryCheckpoint(
|
|
2977
|
+
dsl,
|
|
2978
|
+
nodeExecutions,
|
|
2979
|
+
execution.nodeId,
|
|
2980
|
+
inheritedCheckpoint,
|
|
2981
|
+
);
|
|
2982
|
+
return true;
|
|
2983
|
+
} catch {
|
|
2984
|
+
return false;
|
|
2985
|
+
}
|
|
2986
|
+
})
|
|
2987
|
+
.map((execution) => execution.nodeId);
|
|
2988
|
+
}
|
|
2989
|
+
|
|
1137
2990
|
function normalizeHttpStatusCode(value: unknown): number {
|
|
1138
2991
|
return typeof value === 'number' &&
|
|
1139
2992
|
Number.isInteger(value) &&
|
|
@@ -1150,3 +3003,17 @@ function normalizeHttpHeaders(value: unknown): Record<string, string> {
|
|
|
1150
3003
|
),
|
|
1151
3004
|
);
|
|
1152
3005
|
}
|
|
3006
|
+
|
|
3007
|
+
function normalizeTemporalCompletedResult(
|
|
3008
|
+
value: unknown,
|
|
3009
|
+
): Prisma.InputJsonValue {
|
|
3010
|
+
const result = asRecord(value);
|
|
3011
|
+
const context = asRecord(result.context);
|
|
3012
|
+
return {
|
|
3013
|
+
workflow: asRecord(context.workflow) as Prisma.InputJsonObject,
|
|
3014
|
+
output: (result.output ?? null) as Prisma.InputJsonValue,
|
|
3015
|
+
...(result.httpResponse !== undefined && {
|
|
3016
|
+
httpResponse: result.httpResponse as Prisma.InputJsonValue,
|
|
3017
|
+
}),
|
|
3018
|
+
};
|
|
3019
|
+
}
|