@gadmin2n/schematics 0.0.144 → 0.0.146

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.
Files changed (280) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/.types.d.ts +10 -0
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/approval.prisma +2 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +140 -19
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -2
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/data-mngt.seed.ts +0 -2
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +694 -327
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-outbox.ts +49 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +35 -5
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.service.ts +14 -24
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-executor.spec.ts +23 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-executor.ts +16 -5
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.controller.spec.ts +98 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.controller.ts +26 -6
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/codeRuntime/code-runtime.dto.ts +17 -2
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-runtime.controller.spec.ts +1 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-runtime.controller.ts +3 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-runtime.dto.ts +0 -2
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-signal-outbox.service.spec.ts +30 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction-signal-outbox.service.ts +28 -6
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction.service.spec.ts +63 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-interaction.service.ts +151 -38
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-wait.handler.ts +4 -1
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/human-approval.handler.spec.ts +16 -5
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/human-approval.handler.ts +23 -6
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.controller.spec.ts +130 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.controller.ts +62 -62
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.dto.ts +15 -1
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.util.spec.ts +70 -9
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/httpRequest/http-request-runtime.util.ts +31 -7
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-signal-outbox.service.spec.ts +30 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-signal-outbox.service.ts +28 -6
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.spec.ts +50 -3
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/approval-subject-source.util.ts +26 -3
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.dto.ts +53 -47
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.errors.ts +0 -1
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.service.spec.ts +541 -0
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.service.ts +442 -200
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/outbox-retry.util.ts +1 -0
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.controller.spec.ts +150 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.controller.ts +56 -40
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/runtime/human-approval-runtime.dto.ts +16 -60
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.controller.ts +13 -0
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.spec.ts +44 -0
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/integrationConnection/integration-connection.service.ts +6 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.spec.ts +258 -12
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.ts +127 -14
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/manual-workflow-execution.spec.ts +85 -0
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/manual-workflow-execution.ts +108 -0
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/parameter-binding.ts +5 -5
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.spec.ts +96 -25
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +77 -34
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-callable.spec.ts +23 -34
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-callable.ts +0 -21
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +245 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +639 -188
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +408 -235
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.types.ts +1 -3
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-data-edge.util.spec.ts +279 -95
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-data-edge.util.ts +360 -80
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +323 -0
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +773 -0
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution.dto.ts +7 -13
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.dto.ts +0 -16
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.service.spec.ts +184 -0
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.service.ts +69 -138
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-handle-validator.spec.ts +113 -0
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-handle-validator.ts +59 -13
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-runtime.facade.ts +86 -0
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-switch-config.spec.ts +25 -0
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-switch-config.ts +45 -0
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +78 -43
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +103 -40
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +57 -0
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +3 -1
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +1000 -240
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +2184 -317
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +279 -2
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.ts +5 -1
  79. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.spec.ts +4 -1
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/subject-config-validator.service.ts +5 -1
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/widget-catalog.service.spec.ts +12 -3
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/widget-catalog.service.ts +10 -1
  83. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.service.spec.ts +2 -24
  84. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.service.ts +3 -70
  85. package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +3 -1
  86. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/RowActions.tsx +25 -5
  87. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/WorkflowActionButton.test.tsx +71 -0
  88. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/WorkflowActionButton.tsx +89 -0
  89. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/index.ts +1 -0
  90. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/header.tsx +52 -12
  91. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +12 -0
  92. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/constants/layout.ts +2 -2
  93. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/AddDataModal.tsx +10 -33
  94. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/AddPageModal.tsx +13 -37
  95. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/DevShell.tsx +55 -7
  96. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/dev-shell/style.css +14 -0
  97. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/http.ts +10 -0
  98. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/list.tsx +1 -0
  99. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/workflowAction.ts +19 -0
  100. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/index.ts +1 -0
  101. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/types.ts +3 -1
  102. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +1 -6
  103. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useReferenceUpload.ts +95 -0
  104. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +2 -2
  105. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +672 -110
  106. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +2 -2
  107. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +676 -114
  108. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +13 -1
  109. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +1 -0
  110. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +38 -4
  111. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +276 -376
  112. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/CanvasAiModal.tsx +126 -6
  113. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/DecisionModal.test.tsx +50 -0
  114. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/DecisionModal.tsx +48 -11
  115. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/inbox.tsx +32 -34
  116. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.test.tsx +111 -0
  117. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/RegisterBusinessEventModal.tsx +167 -0
  118. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/business-events.css +99 -0
  119. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.test.tsx +106 -0
  120. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +324 -0
  121. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.test.tsx +73 -0
  122. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +359 -0
  123. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CanvasZoomControls.tsx +46 -0
  124. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CreateNodeInstanceDrawer.tsx +9 -1
  125. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CustomNode.test.tsx +105 -4
  126. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CustomNode.tsx +90 -48
  127. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DeletableEdge.tsx +3 -2
  128. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.test.tsx +259 -0
  129. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.tsx +167 -103
  130. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +142 -140
  131. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.test.tsx +60 -15
  132. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.tsx +25 -12
  133. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/FlowRenderer.tsx +364 -200
  134. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NewNodeDefaults.test.tsx +220 -52
  135. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeOutputSummary.test.tsx +260 -0
  136. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeOutputSummary.tsx +300 -130
  137. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +507 -33
  138. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.tsx +630 -592
  139. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ParameterBindingEditor.tsx +67 -70
  140. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +90 -0
  141. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.tsx +325 -0
  142. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/RunWorkflowModal.test.tsx +52 -0
  143. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/RunWorkflowModal.tsx +14 -9
  144. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/StatusCards.tsx +2 -1
  145. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/VersionPanel.tsx +2 -1
  146. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.test.tsx +42 -0
  147. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowCodeEditor.tsx +76 -0
  148. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/WorkflowWorkspaceHeader.tsx +56 -0
  149. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/parameter-binding-editor.css +46 -0
  150. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/workflow-code-editor.css +34 -0
  151. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/ConnectionModal.test.tsx +53 -7
  152. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/ConnectionModal.tsx +123 -62
  153. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connections.css +85 -0
  154. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/connectionsApi.ts +29 -0
  155. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.test.tsx +75 -0
  156. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +69 -36
  157. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.test.tsx +494 -0
  158. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +665 -194
  159. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useReferencedNodeInstanceConfigs.test.tsx +92 -0
  160. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useReferencedNodeInstanceConfigs.ts +96 -0
  161. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useWorkflowAgent.test.tsx +297 -0
  162. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useWorkflowAgent.ts +135 -65
  163. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +53 -42
  164. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +344 -155
  165. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instances.tsx +79 -58
  166. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/CreateNodeInstanceModal.tsx +123 -144
  167. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/NodeInstanceForm.tsx +169 -98
  168. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/node-instance-form.css +91 -0
  169. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/index.tsx +54 -31
  170. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-config.css +854 -0
  171. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/SchemaField.tsx +41 -14
  172. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/SchemaForm.test.tsx +75 -0
  173. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/SchemaForm.tsx +3 -0
  174. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +20 -2
  175. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/widget-registry.tsx +2 -1
  176. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +61 -66
  177. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApprovalRuleEditor.test.tsx +33 -1
  178. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApprovalRuleEditor.tsx +47 -15
  179. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/BranchConfigEditors.test.tsx +104 -0
  180. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CallableInputParametersEditor.tsx +22 -52
  181. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CodeConfigEditor.test.tsx +72 -0
  182. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CodeConfigEditor.tsx +78 -70
  183. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/CronTriggerConfigEditor.tsx +569 -0
  184. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +130 -0
  185. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DelayConfigEditor.tsx +100 -0
  186. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +620 -0
  187. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.test.tsx +22 -45
  188. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.tsx +25 -92
  189. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalWaitConfigEditor.tsx +3 -11
  190. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ForEachConfigEditor.test.tsx +58 -0
  191. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ForEachConfigEditor.tsx +107 -0
  192. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HttpRequestConfigEditor.test.tsx +212 -31
  193. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HttpRequestConfigEditor.tsx +214 -304
  194. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.test.tsx +175 -40
  195. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/HumanApprovalConfigEditor.tsx +693 -293
  196. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/IfElseConfigEditor.tsx +190 -0
  197. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ManualTriggerConfigEditor.test.tsx +152 -0
  198. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ManualTriggerConfigEditor.tsx +286 -0
  199. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ParallelConfigEditor.tsx +101 -0
  200. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ParameterBindingListEditor.tsx +99 -0
  201. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +495 -0
  202. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubWorkflowConfigEditor.test.tsx +124 -0
  203. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubWorkflowConfigEditor.tsx +222 -0
  204. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SubjectPicker.tsx +2 -4
  205. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SwitchConfigEditor.tsx +204 -0
  206. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/UserGroupCreateModal.tsx +3 -1
  207. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/UserGroupEditModal.tsx +9 -6
  208. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/callable-workflow.ts +4 -0
  209. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/code-config.css +96 -0
  210. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/event-catalog.api.ts +117 -0
  211. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/http-request-config.css +242 -0
  212. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/human-approval-config.css +194 -0
  213. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/show.tsx +264 -167
  214. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +6 -9
  215. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types/parameter-binding.ts +19 -16
  216. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +28 -4
  217. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/useWorkflowFormValidation.test.tsx +59 -0
  218. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/useWorkflowFormValidation.ts +24 -0
  219. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.test.ts +311 -0
  220. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +198 -0
  221. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/mainFlowConnection.test.ts +143 -0
  222. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/mainFlowConnection.ts +19 -0
  223. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +103 -5
  224. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/removeWorkflowEdges.test.ts +158 -3
  225. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/removeWorkflowEdges.ts +117 -9
  226. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveEffectiveOutput.test.ts +70 -0
  227. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveEffectiveOutput.ts +48 -0
  228. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveLookupPorts.test.ts +55 -0
  229. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveLookupPorts.ts +95 -22
  230. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveOutputs.test.ts +148 -1
  231. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveOutputs.ts +62 -11
  232. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveWorkflowDisplay.test.ts +19 -0
  233. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveWorkflowDisplay.ts +1 -1
  234. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/subWorkflowChange.test.ts +45 -0
  235. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/subWorkflowChange.ts +26 -0
  236. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/validateWorkflowDsl.test.ts +502 -0
  237. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/validateWorkflowDsl.ts +694 -0
  238. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowDateTime.test.ts +27 -0
  239. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowDateTime.ts +33 -0
  240. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowHistory.test.ts +34 -0
  241. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowHistory.ts +52 -0
  242. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowPublishError.test.ts +99 -0
  243. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowPublishError.ts +91 -0
  244. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowSkillPrompt.test.ts +74 -0
  245. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/workflowSkillPrompt.ts +120 -0
  246. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/workflow-ui.css +481 -0
  247. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/styles/index.css +1 -1
  248. package/package.json +1 -1
  249. package/dist/lib/application/files/gadmin2-game-angle-demo/config/ui/WorkflowEventOutbox.ts +0 -62
  250. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/migrate-human-approval-configs.ts +0 -86
  251. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-event-trigger.ts +0 -60
  252. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflows.ts +0 -698
  253. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/human-approval.external-decision.spec.ts +0 -183
  254. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/humanApproval/legacy-approval-config-migration.ts +0 -227
  255. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.spec.ts +0 -82
  256. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.ts +0 -111
  257. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.controller.spec.ts +0 -54
  258. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.controller.ts +0 -147
  259. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.module.ts +0 -10
  260. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.service.spec.ts +0 -350
  261. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.service.ts +0 -110
  262. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/audit/index.ts +0 -1
  263. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/InstanceRequiredDialog.tsx +0 -106
  264. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/LookupConnectionDialog.test.tsx +0 -45
  265. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/LookupConnectionDialog.tsx +0 -43
  266. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodeTypeBadge.tsx +0 -36
  267. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useNodeInstanceLibrary.ts +0 -41
  268. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApproverSourceEditor.test.tsx +0 -47
  269. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ApproverSourceEditor.tsx +0 -182
  270. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.test.tsx +0 -34
  271. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.tsx +0 -16
  272. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/approver-source.test.ts +0 -25
  273. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/approver-source.ts +0 -42
  274. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/lookupConnection.test.ts +0 -208
  275. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/lookupConnection.ts +0 -246
  276. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/create.tsx +0 -111
  277. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/edit.tsx +0 -127
  278. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/index.tsx +0 -4
  279. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflowEventOutbox/list.tsx +0 -282
  280. 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
- describeCallableWorkflow,
16
- validateCallableWorkflow,
17
- } from './workflow-callable';
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 `cron` field is missing.
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
- ): { cron: string; timezone?: string } | null {
371
+ ): { cronExpressions: string[]; timezone?: string } | null {
62
372
  const node = dsl?.nodes?.find((n: any) => n?.type === 'cron_trigger');
63
- if (!node?.config?.cron) return null;
64
- return { cron: node.config.cron, timezone: node.config.timezone };
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: { status: 'PUBLISHED', isEnabled: true },
639
+ where: { isEnabled: true, versions: { some: {} } },
196
640
  orderBy: { name: 'asc' },
197
641
  include: {
198
642
  versions: {
199
643
  orderBy: { version: 'desc' },
200
- take: 1,
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
- try {
207
- const descriptor = describeCallableWorkflow(workflow.versions[0]?.dsl);
208
- if (!descriptor) return [];
209
- return [
210
- {
211
- id: String(workflow.id),
212
- name: workflow.name,
213
- description: workflow.description,
214
- inputParameters: descriptor.inputParameters,
215
- },
216
- ];
217
- } catch {
218
- return [];
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: { status: 'PUBLISHED', isEnabled: true },
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
- return workflow;
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.validateCallableDraft(dsl);
307
- const latestPublishedDsl = workflow.versions?.[0]?.dsl;
308
- const status =
309
- latestPublishedDsl && areWorkflowDslSourcesEqual(dsl, latestPublishedDsl)
310
- ? 'PUBLISHED'
311
- : 'DRAFT';
312
- return this.prisma.workflow.update({
313
- where: { id },
314
- data: { dsl, status },
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.workflow.findUnique({ where: { id } });
320
- if (!workflow) throw new NotFoundException('Workflow not found');
321
- const updated = await this.prisma.workflow.update({
322
- where: { id },
323
- data: { isEnabled: enabled },
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
- // Cron schedule lifecycle: pause when disabled, resume when re-enabled
1267
+ return tx.workflow.findUniqueOrThrow({ where: { id } });
1268
+ });
327
1269
  try {
328
- if (enabled) {
329
- await this.temporalService.unpauseCronSchedule(Number(id));
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.warn(
335
- `Failed to ${
336
- enabled ? 'unpause' : 'pause'
337
- } cron schedule for workflow ${id}: ${err?.message ?? err}`,
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
- // Best-effort: delete cron schedule before removing the workflow row
349
- try {
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
- // Validate source handles against the same node-type metadata consumed by
452
- // the canvas. This also supports project-specific node types and output
453
- // declarations without requiring a nest-common release.
454
- const handleDsl = dslToPublish as any;
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
- .filter((edge: any) => !edge.kind || edge.kind === 'FLOW')
475
- .map((edge: any) =>
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 sourceHandle(s)',
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
- // Validate webhook path collisions across other published workflows
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
- if (webhookConfigs.length > 0) {
508
- const otherPublished = await this.prisma.workflow.findMany({
509
- where: { status: 'PUBLISHED', id: { not: workflowId } },
510
- select: {
511
- id: true,
512
- name: true,
513
- versions: {
514
- orderBy: { version: 'desc' },
515
- take: 1,
516
- select: { dsl: true },
517
- },
518
- },
519
- });
520
- for (const cfg of webhookConfigs) {
521
- if (cfg.verificationMode === 'HMAC' && !cfg.secret) {
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
- if (cronConfig) {
587
- await this.temporalService.upsertCronSchedule({
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.warn(
600
- `Cron schedule sync failed for workflow ${workflowId}: ${
601
- err?.message ?? err
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: any,
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 !== 'PUBLISHED') {
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(dto.workflow);
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
- // Create a WorkflowInstance record
775
- const instance = await this.prisma.workflowInstance.create({
776
- data: {
777
- workflowId,
778
- versionId: version.id,
779
- status: 'PENDING',
780
- context: executionContext,
781
- creator,
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
- // Start the Temporal workflow
786
- const { temporalRunId } = await temporalService.startWorkflow({
787
- instanceId: Number(instance.id),
788
- workflowId: Number(workflowId),
789
- versionId: Number(version.id),
790
- dsl: version.dsl,
791
- context: executionContext,
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
- // Update instance with Temporal run ID
795
- await this.prisma.workflowInstance.update({
796
- where: { id: instance.id },
797
- data: { temporalRunId },
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
- instanceId: instance.id.toString(),
802
- temporalRunId,
803
- workflowVersionId: version.id.toString(),
804
- status: 'PENDING',
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: { status: 'PUBLISHED', isEnabled: true },
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
- { workflow: workflowData, workflowVersionId: m.versionId },
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
- const instanceIds = results.map((result) => result.instanceId);
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 cancelInstance(instanceId: bigint, temporalService: any) {
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
- if (!instance) throw new NotFoundException('Instance not found');
1027
- if (instance.status !== 'RUNNING' && instance.status !== 'PENDING') {
1028
- throw new Error('Can only cancel RUNNING or PENDING instances');
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
- if (instance.temporalRunId) {
1032
- await temporalService.cancelWorkflow(instance.temporalRunId);
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
- await this.prisma.workflowInstance.update({
1036
- where: { id: instanceId },
1037
- data: { status: 'CANCELLED', finishedAt: new Date() },
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
- return { success: true };
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 signalInstance(
2689
+ async retryInstanceFromNode(
1076
2690
  instanceId: bigint,
1077
- dto: { signalName: string; payload: Record<string, any> },
1078
- temporalService: any,
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 (!instance.temporalRunId) {
1086
- throw new Error('Instance has no Temporal run ID');
2700
+ if (instance.status !== 'FAILED') {
2701
+ throw new BadRequestException('只有失败的 Workflow 才能从节点重试');
1087
2702
  }
1088
2703
 
1089
- await temporalService.signalWorkflow(
1090
- instance.temporalRunId,
1091
- dto.signalName,
1092
- dto.payload,
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
+ }