@gadmin2n/schematics 0.0.152 → 0.0.153

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 (43) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.local.md +8 -0
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +1 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +1 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/index.ts +2 -1
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permission.seed.ts +23 -44
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +25 -4
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +74 -64
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +0 -7
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +17 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +58 -3
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +66 -6
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +61 -149
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution.dto.ts +1 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +1 -1
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +1 -1
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +2 -2
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +38 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +18 -7
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.dto.ts +1 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +131 -242
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts +7 -5
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +6 -36
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -4
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +29 -7
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +29 -7
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/people-groups/index.tsx +20 -17
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/person-directory/index.tsx +38 -35
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/detail.tsx +6 -1
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/business-events/index.tsx +24 -22
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +0 -11
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/StatusCards.tsx +24 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +20 -18
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +7 -0
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +28 -79
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instances.tsx +5 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/components/NodeInstanceForm.tsx +0 -1
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +227 -97
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +62 -0
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/settings/index.tsx +73 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +21 -46
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +2 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -0
  43. package/package.json +1 -1
@@ -19,11 +19,7 @@ import {
19
19
  scopedIdempotencyKey,
20
20
  workflowDataHash,
21
21
  } from './manual-workflow-execution';
22
- import {
23
- TemporalService,
24
- WorkflowRetryCheckpoint,
25
- workflowTemporalId,
26
- } from './temporal.service';
22
+ import { TemporalService, workflowTemporalId } from './temporal.service';
27
23
  import { verifyWebhookSignature } from './webhook-signature.util';
28
24
  import { describeCallableWorkflow } from './workflow-callable';
29
25
  import { WorkflowCompilerService } from './workflow-compiler.service';
@@ -97,19 +93,22 @@ type CronSchedulePlan =
97
93
  version: { id: bigint; dsl: unknown };
98
94
  };
99
95
  const WORKFLOW_PUBLISH_LOCK_KEY = BigInt(734_221_907);
100
- const RETRYABLE_MAIN_FLOW_NODE_TYPES = new Set([
101
- 'http_request',
102
- 'db_execute',
103
- 'code',
104
- 'delay',
105
- 'sub_workflow',
106
- ]);
107
-
108
96
  interface WorkflowExecutionInternalOptions {
109
97
  operationScopeId?: string;
110
- retryCheckpoint?: WorkflowRetryCheckpoint;
111
98
  retryOfInstanceId?: bigint;
112
- retryFromNodeId?: string;
99
+ }
100
+
101
+ export type WorkflowServiceOutcome<T> =
102
+ | { kind: 'ok'; value: T }
103
+ | { kind: 'not_found'; message: string }
104
+ | { kind: 'conflict'; message: string }
105
+ | { kind: 'invalid_input'; message: string };
106
+
107
+ export interface WorkflowExecutionResult {
108
+ instanceId: string;
109
+ temporalRunId: string | null;
110
+ workflowVersionId: string;
111
+ status: string;
113
112
  }
114
113
 
115
114
  function dslUsesBusinessEvent(dsl: unknown, registrationCode: string): boolean {
@@ -644,6 +643,7 @@ export class WorkflowService {
644
643
  {
645
644
  pending: number;
646
645
  running: number;
646
+ waiting_retry: number;
647
647
  completed: number;
648
648
  failed: number;
649
649
  cancelled: number;
@@ -654,6 +654,7 @@ export class WorkflowService {
654
654
  runsMap.set(row.workflowId, {
655
655
  pending: 0,
656
656
  running: 0,
657
+ waiting_retry: 0,
657
658
  completed: 0,
658
659
  failed: 0,
659
660
  cancelled: 0,
@@ -681,6 +682,7 @@ export class WorkflowService {
681
682
  runs: runsMap.get(item.id) || {
682
683
  pending: 0,
683
684
  running: 0,
685
+ waiting_retry: 0,
684
686
  completed: 0,
685
687
  failed: 0,
686
688
  cancelled: 0,
@@ -1407,7 +1409,7 @@ export class WorkflowService {
1407
1409
  const activeInstances = await tx.workflowInstance.count({
1408
1410
  where: {
1409
1411
  workflowId,
1410
- status: { in: ['PENDING', 'RUNNING'] },
1412
+ status: { in: ['PENDING', 'RUNNING', 'WAITING_RETRY'] },
1411
1413
  },
1412
1414
  });
1413
1415
  if (activeInstances > 0)
@@ -1727,19 +1729,24 @@ export class WorkflowService {
1727
1729
  }
1728
1730
 
1729
1731
  async getStats(workflowId: bigint) {
1730
- const [running, failed, completed, total] = await Promise.all([
1731
- this.prisma.workflowInstance.count({
1732
- where: { workflowId, status: 'RUNNING' },
1733
- }),
1734
- this.prisma.workflowInstance.count({
1735
- where: { workflowId, status: 'FAILED' },
1736
- }),
1737
- this.prisma.workflowInstance.count({
1738
- where: { workflowId, status: 'COMPLETED' },
1739
- }),
1740
- this.prisma.workflowInstance.count({ where: { workflowId } }),
1741
- ]);
1742
- return { running, failed, completed, total };
1732
+ const [running, waitingRetry, failed, completed, total] = await Promise.all(
1733
+ [
1734
+ this.prisma.workflowInstance.count({
1735
+ where: { workflowId, status: 'RUNNING' },
1736
+ }),
1737
+ this.prisma.workflowInstance.count({
1738
+ where: { workflowId, status: 'WAITING_RETRY' },
1739
+ }),
1740
+ this.prisma.workflowInstance.count({
1741
+ where: { workflowId, status: 'FAILED' },
1742
+ }),
1743
+ this.prisma.workflowInstance.count({
1744
+ where: { workflowId, status: 'COMPLETED' },
1745
+ }),
1746
+ this.prisma.workflowInstance.count({ where: { workflowId } }),
1747
+ ],
1748
+ );
1749
+ return { running, waitingRetry, failed, completed, total };
1743
1750
  }
1744
1751
 
1745
1752
  async getWorkflowStatsByCode(code: string) {
@@ -1764,6 +1771,7 @@ export class WorkflowService {
1764
1771
  failed24h,
1765
1772
  running,
1766
1773
  pending,
1774
+ waitingRetry,
1767
1775
  ] = await Promise.all([
1768
1776
  this.prisma.workflowInstance.count(),
1769
1777
  this.prisma.workflowInstance.count({
@@ -1779,6 +1787,9 @@ export class WorkflowService {
1779
1787
  }),
1780
1788
  this.prisma.workflowInstance.count({ where: { status: 'RUNNING' } }),
1781
1789
  this.prisma.workflowInstance.count({ where: { status: 'PENDING' } }),
1790
+ this.prisma.workflowInstance.count({
1791
+ where: { status: 'WAITING_RETRY' },
1792
+ }),
1782
1793
  ]);
1783
1794
 
1784
1795
  // Success rate calculation
@@ -1828,6 +1839,7 @@ export class WorkflowService {
1828
1839
  },
1829
1840
  running,
1830
1841
  pending,
1842
+ waitingRetry,
1831
1843
  };
1832
1844
  }
1833
1845
 
@@ -1902,7 +1914,6 @@ export class WorkflowService {
1902
1914
  const executionContext = createWorkflowExecutionState(
1903
1915
  dto.workflow,
1904
1916
  internal?.operationScopeId,
1905
- internal?.retryCheckpoint,
1906
1917
  );
1907
1918
  const idempotencyRequestHash = dto.idempotencyKey
1908
1919
  ? workflowDataHash(
@@ -1938,7 +1949,6 @@ export class WorkflowService {
1938
1949
  }),
1939
1950
  ...(internal?.retryOfInstanceId && {
1940
1951
  retryOfInstanceId: internal.retryOfInstanceId,
1941
- retryFromNodeId: internal.retryFromNodeId,
1942
1952
  }),
1943
1953
  creator,
1944
1954
  },
@@ -2011,7 +2021,6 @@ export class WorkflowService {
2011
2021
  versionId: Number(instanceVersion.id),
2012
2022
  dsl: instanceVersion.dsl,
2013
2023
  context: instance.context as Record<string, any>,
2014
- retryCheckpoint: readRetryCheckpoint(instance.context),
2015
2024
  });
2016
2025
  } catch (error) {
2017
2026
  await this.workflowLifecycle.markFailed({
@@ -2250,7 +2259,6 @@ export class WorkflowService {
2250
2259
  select: {
2251
2260
  id: true,
2252
2261
  status: true,
2253
- retryFromNodeId: true,
2254
2262
  createdAt: true,
2255
2263
  },
2256
2264
  }),
@@ -2271,16 +2279,10 @@ export class WorkflowService {
2271
2279
  workflow: { name: instance.workflow.name },
2272
2280
  approvableNodeIds: approvableRequests.map((request) => request.nodeId),
2273
2281
  retryableNodeIds:
2274
- instance.status === 'FAILED' && dsl && retryAttempts.length === 0
2275
- ? listRetryableNodeIds(
2276
- dsl,
2277
- instance.nodeExecutions,
2278
- readRetryCheckpoint(instance.context),
2279
- )
2282
+ instance.status === 'WAITING_RETRY' && dsl
2283
+ ? listSameRunRetryableNodeIds(dsl, instance.nodeExecutions)
2280
2284
  : [],
2281
2285
  retryAttempts,
2282
- reusedNodeIds:
2283
- readRetryCheckpoint(instance.context)?.completedNodeIds ?? [],
2284
2286
  dsl,
2285
2287
  };
2286
2288
  }
@@ -2306,7 +2308,7 @@ export class WorkflowService {
2306
2308
  ...(Object.prototype.hasOwnProperty.call(result, 'output') && {
2307
2309
  result: result.output,
2308
2310
  }),
2309
- ...(instance.status === 'FAILED' && {
2311
+ ...(['FAILED', 'WAITING_RETRY'].includes(instance.status) && {
2310
2312
  error: {
2311
2313
  ...(typeof result.code === 'string' ? { code: result.code } : {}),
2312
2314
  message:
@@ -2347,10 +2349,10 @@ export class WorkflowService {
2347
2349
  if (!instance) throw new NotFoundException('Workflow instance not found');
2348
2350
  if (
2349
2351
  instance.status !== 'CANCELLED' &&
2350
- !['RUNNING', 'PENDING'].includes(instance.status)
2352
+ !['RUNNING', 'PENDING', 'WAITING_RETRY'].includes(instance.status)
2351
2353
  )
2352
2354
  throw new ConflictException(
2353
- 'Only RUNNING or PENDING instances can be cancelled',
2355
+ 'Only RUNNING, PENDING, or WAITING_RETRY instances can be cancelled',
2354
2356
  );
2355
2357
  if (
2356
2358
  instance.status !== 'CANCELLED' &&
@@ -2510,7 +2512,7 @@ export class WorkflowService {
2510
2512
 
2511
2513
  const reservedInstances = await this.prisma.workflowInstance.findMany({
2512
2514
  where: {
2513
- status: { in: ['PENDING', 'RUNNING'] },
2515
+ status: { in: ['PENDING', 'RUNNING', 'WAITING_RETRY'] },
2514
2516
  temporalRunId: { not: null },
2515
2517
  updatedAt: { lte: cutoff },
2516
2518
  },
@@ -2529,7 +2531,10 @@ export class WorkflowService {
2529
2531
  instance.temporalRunId!,
2530
2532
  );
2531
2533
  if (temporalState.kind === 'MISSING') {
2532
- if (instance.status === 'RUNNING') {
2534
+ if (
2535
+ instance.status === 'RUNNING' ||
2536
+ instance.status === 'WAITING_RETRY'
2537
+ ) {
2533
2538
  await this.failMissingRunningInstance(
2534
2539
  instance.id,
2535
2540
  instance.temporalRunId!,
@@ -2555,7 +2560,6 @@ export class WorkflowService {
2555
2560
  versionId: Number(instance.versionId),
2556
2561
  dsl: version.dsl,
2557
2562
  context: (instance.context ?? {}) as Record<string, any>,
2558
- retryCheckpoint: readRetryCheckpoint(instance.context),
2559
2563
  });
2560
2564
  } catch (error) {
2561
2565
  await this.failUnstartedInstance(
@@ -2599,7 +2603,7 @@ export class WorkflowService {
2599
2603
  ): Promise<void> {
2600
2604
  await this.workflowLifecycle.markFailed({
2601
2605
  instanceId,
2602
- expectedStatuses: ['RUNNING'],
2606
+ expectedStatuses: ['RUNNING', 'WAITING_RETRY'],
2603
2607
  temporalRunId,
2604
2608
  result: {
2605
2609
  code: 'TEMPORAL_EXECUTION_MISSING',
@@ -2685,7 +2689,7 @@ export class WorkflowService {
2685
2689
  if (state.status !== 'COMPLETED' && state.status !== 'CANCELLED') {
2686
2690
  await this.workflowLifecycle.markFailed({
2687
2691
  instanceId,
2688
- expectedStatuses: ['PENDING', 'RUNNING'],
2692
+ expectedStatuses: ['PENDING', 'RUNNING', 'WAITING_RETRY'],
2689
2693
  temporalRunId,
2690
2694
  result:
2691
2695
  state.result === undefined
@@ -2702,7 +2706,7 @@ export class WorkflowService {
2702
2706
  await this.prisma.workflowInstance.updateMany({
2703
2707
  where: {
2704
2708
  id: instanceId,
2705
- status: { in: ['PENDING', 'RUNNING'] },
2709
+ status: { in: ['PENDING', 'RUNNING', 'WAITING_RETRY'] },
2706
2710
  temporalRunId,
2707
2711
  },
2708
2712
  data: {
@@ -2785,59 +2789,72 @@ export class WorkflowService {
2785
2789
  );
2786
2790
  }
2787
2791
 
2788
- async retryInstanceFromNode(
2792
+ async retryFailedNode(
2789
2793
  instanceId: bigint,
2790
2794
  nodeId: string,
2791
- creator: string,
2792
2795
  temporalService: TemporalService,
2793
- ) {
2796
+ ): Promise<WorkflowServiceOutcome<WorkflowExecutionResult>> {
2794
2797
  const instance = await this.prisma.workflowInstance.findUnique({
2795
2798
  where: { id: instanceId },
2796
2799
  include: { nodeExecutions: true },
2797
2800
  });
2798
- if (!instance) throw new NotFoundException('Instance not found');
2799
- if (instance.status !== 'FAILED') {
2800
- throw new BadRequestException('只有失败的 Workflow 才能从节点重试');
2801
- }
2802
-
2803
- const existingRetry = await this.prisma.workflowInstance.findFirst({
2804
- where: { retryOfInstanceId: instance.id },
2805
- orderBy: { createdAt: 'desc' },
2806
- });
2807
- if (existingRetry) return workflowExecutionResult(existingRetry);
2801
+ if (!instance) return { kind: 'not_found', message: 'Instance not found' };
2802
+ if (instance.status !== 'WAITING_RETRY')
2803
+ return {
2804
+ kind: 'conflict',
2805
+ message: '只有等待重试的 Workflow 才能重试失败节点',
2806
+ };
2808
2807
 
2809
2808
  const version = await this.prisma.workflowVersion.findUnique({
2810
2809
  where: { id: instance.versionId },
2811
2810
  select: { dsl: true },
2812
2811
  });
2813
- if (!version) throw new NotFoundException('Workflow version not found');
2812
+ if (!version)
2813
+ return { kind: 'not_found', message: 'Workflow version not found' };
2814
2814
 
2815
- const retryCheckpoint = buildRetryCheckpoint(
2815
+ const invalidTargetMessage = validateSameRunRetryTarget(
2816
2816
  version.dsl,
2817
2817
  instance.nodeExecutions,
2818
2818
  nodeId,
2819
- readRetryCheckpoint(instance.context),
2820
2819
  );
2821
- const executionState = asRecord(instance.context);
2822
- const operationScopeId =
2823
- asRecord(executionState.system).operationScopeId ?? String(instance.id);
2824
-
2825
- return this.executeWorkflow(
2826
- instance.workflowId,
2827
- {
2828
- workflow: asRecord(executionState.workflow),
2829
- workflowVersionId: instance.versionId,
2830
- idempotencyKey: `retry:${instance.id}`,
2831
- },
2832
- creator,
2833
- temporalService,
2834
- {
2835
- operationScopeId: String(operationScopeId),
2836
- retryCheckpoint,
2837
- retryOfInstanceId: instance.id,
2838
- retryFromNodeId: nodeId,
2839
- },
2820
+ if (invalidTargetMessage)
2821
+ return { kind: 'invalid_input', message: invalidTargetMessage };
2822
+ if (!instance.temporalRunId)
2823
+ return {
2824
+ kind: 'conflict',
2825
+ message: '当前 Workflow 没有可继续的 Temporal Run',
2826
+ };
2827
+ const failedExecution = instance.nodeExecutions.find(
2828
+ (execution) =>
2829
+ execution.nodeId === nodeId && execution.status === 'FAILED',
2840
2830
  );
2831
+ if (!failedExecution)
2832
+ return { kind: 'invalid_input', message: '只能重试当前失败的节点' };
2833
+ try {
2834
+ await temporalService.signalWorkflow(
2835
+ instance.temporalRunId,
2836
+ 'retryNode',
2837
+ {
2838
+ nodeId,
2839
+ requestId: `retry-node:${
2840
+ instance.id
2841
+ }:${nodeId}:${failedExecution.updatedAt.toISOString()}`,
2842
+ },
2843
+ );
2844
+ } catch (error) {
2845
+ this.logger.error(
2846
+ `workflow_node_retry_signal_failed - instanceId: ${
2847
+ instance.id
2848
+ }, nodeId: ${nodeId}, error: ${
2849
+ error instanceof Error ? error.message : String(error)
2850
+ }`,
2851
+ );
2852
+ throw error;
2853
+ }
2854
+ this.logger.log(
2855
+ `workflow_node_retry_signal_sent - instanceId: ${instance.id}, nodeId: ${nodeId}`,
2856
+ );
2857
+ return { kind: 'ok', value: workflowExecutionResult(instance) };
2841
2858
  }
2842
2859
 
2843
2860
  async approveInstance(
@@ -2868,15 +2885,13 @@ export class WorkflowService {
2868
2885
  function createWorkflowExecutionState(
2869
2886
  value: Record<string, any> | undefined,
2870
2887
  operationScopeId?: string,
2871
- retryCheckpoint?: WorkflowRetryCheckpoint,
2872
2888
  ): Record<string, any> {
2873
2889
  return {
2874
2890
  workflow: value ?? {},
2875
2891
  nodes: {},
2876
- ...((operationScopeId || retryCheckpoint) && {
2892
+ ...(operationScopeId && {
2877
2893
  system: {
2878
- ...(operationScopeId && { operationScopeId }),
2879
- ...(retryCheckpoint && { retryCheckpoint }),
2894
+ operationScopeId,
2880
2895
  },
2881
2896
  }),
2882
2897
  };
@@ -2887,7 +2902,7 @@ function workflowExecutionResult(instance: {
2887
2902
  versionId: bigint;
2888
2903
  status: string;
2889
2904
  temporalRunId: string | null;
2890
- }) {
2905
+ }): WorkflowExecutionResult {
2891
2906
  return {
2892
2907
  instanceId: instance.id.toString(),
2893
2908
  temporalRunId: instance.temporalRunId,
@@ -2910,50 +2925,11 @@ function quoteLiteral(value: string): string {
2910
2925
  return `'${value.replace(/'/g, "''")}'`;
2911
2926
  }
2912
2927
 
2913
- function readRetryCheckpoint(
2914
- value: unknown,
2915
- ): WorkflowRetryCheckpoint | undefined {
2916
- const checkpoint = asRecord(asRecord(value).system).retryCheckpoint;
2917
- if (
2918
- !checkpoint ||
2919
- typeof checkpoint !== 'object' ||
2920
- Array.isArray(checkpoint)
2921
- )
2922
- return undefined;
2923
- const record = checkpoint as Record<string, unknown>;
2924
- if (
2925
- typeof record.startNodeId !== 'string' ||
2926
- !Array.isArray(record.completedNodeIds) ||
2927
- !record.completedNodeIds.every((item) => typeof item === 'string') ||
2928
- !record.outputs ||
2929
- typeof record.outputs !== 'object' ||
2930
- Array.isArray(record.outputs)
2931
- )
2932
- return undefined;
2933
- const outputs = record.outputs as Record<string, unknown>;
2934
- if (
2935
- !record.completedNodeIds.every((nodeId) =>
2936
- Object.prototype.hasOwnProperty.call(outputs, nodeId),
2937
- )
2938
- )
2939
- return undefined;
2940
- return {
2941
- startNodeId: record.startNodeId,
2942
- completedNodeIds: record.completedNodeIds,
2943
- outputs,
2944
- };
2945
- }
2946
-
2947
- function buildRetryCheckpoint(
2928
+ function validateSameRunRetryTarget(
2948
2929
  dslValue: unknown,
2949
- nodeExecutions: Array<{
2950
- nodeId: string;
2951
- status: string;
2952
- output: unknown;
2953
- }>,
2954
- retryFromNodeId: string,
2955
- inheritedCheckpoint?: WorkflowRetryCheckpoint,
2956
- ): WorkflowRetryCheckpoint {
2930
+ nodeExecutions: Array<{ nodeId: string; status: string }>,
2931
+ nodeId: string,
2932
+ ): string | undefined {
2957
2933
  const dsl = asRecord(dslValue);
2958
2934
  const nodes = Array.isArray(dsl.nodes)
2959
2935
  ? (dsl.nodes as Array<Record<string, unknown>>)
@@ -2961,128 +2937,41 @@ function buildRetryCheckpoint(
2961
2937
  const edges = Array.isArray(dsl.edges)
2962
2938
  ? (dsl.edges as Array<Record<string, unknown>>)
2963
2939
  : [];
2964
- const target = nodes.find((node) => node.id === retryFromNodeId);
2965
- if (!target) throw new BadRequestException('失败节点不在原发布版本中');
2966
- if (!RETRYABLE_MAIN_FLOW_NODE_TYPES.has(String(target.type))) {
2967
- throw new BadRequestException('该节点暂不支持从这里重试');
2968
- }
2940
+ const target = nodes.find((node) => node.id === nodeId);
2941
+ if (!target) return '失败节点不在原发布版本中';
2942
+ const nodeType = String(target.type ?? '');
2943
+ if (nodeType.endsWith('_trigger')) return '触发节点不能重试';
2944
+ const isCompositeOwner = nodeType === 'parallel' || nodeType === 'for_each';
2969
2945
  if (
2946
+ !isCompositeOwner &&
2970
2947
  edges.some(
2971
2948
  (edge) =>
2972
2949
  typeof edge.pairId === 'string' &&
2973
2950
  edge.pairId.length > 0 &&
2974
- (edge.source === retryFromNodeId || edge.target === retryFromNodeId),
2951
+ (edge.source === nodeId || edge.target === nodeId),
2975
2952
  )
2976
- ) {
2977
- throw new BadRequestException('暂不支持从循环或并行分支内部重试');
2978
- }
2979
-
2980
- const executionMap = new Map<
2981
- string,
2982
- { nodeId: string; status: string; output: unknown }
2983
- >();
2984
- for (const completedNodeId of inheritedCheckpoint?.completedNodeIds ?? []) {
2985
- executionMap.set(completedNodeId, {
2986
- nodeId: completedNodeId,
2987
- status: 'COMPLETED',
2988
- output: inheritedCheckpoint?.outputs[completedNodeId],
2989
- });
2990
- }
2991
- for (const execution of nodeExecutions) {
2992
- executionMap.set(execution.nodeId, execution);
2993
- }
2994
- if (executionMap.get(retryFromNodeId)?.status !== 'FAILED') {
2995
- throw new BadRequestException('只能从实际失败的节点重试');
2996
- }
2997
-
2998
- const mainPredecessors = new Map<string, string[]>();
2999
- for (const edge of edges) {
3000
- if (
3001
- (edge.kind === undefined || edge.kind === 'FLOW') &&
3002
- !edge.pairId &&
3003
- typeof edge.source === 'string' &&
3004
- typeof edge.target === 'string'
3005
- ) {
3006
- const predecessors = mainPredecessors.get(edge.target) ?? [];
3007
- predecessors.push(edge.source);
3008
- mainPredecessors.set(edge.target, predecessors);
3009
- }
3010
- }
3011
-
3012
- const nodeMap = new Map(
3013
- nodes
3014
- .filter((node) => typeof node.id === 'string')
3015
- .map((node) => [String(node.id), node]),
3016
- );
3017
- const ancestors = new Set<string>();
3018
- const pending = [...(mainPredecessors.get(retryFromNodeId) ?? [])];
3019
- while (pending.length > 0) {
3020
- const current = pending.pop();
3021
- if (!current) continue;
3022
- if (ancestors.has(current)) continue;
3023
- ancestors.add(current);
3024
- pending.push(...(mainPredecessors.get(current) ?? []));
3025
- }
3026
-
3027
- const completedNodeIds: string[] = [];
3028
- const outputs: Record<string, unknown> = {};
3029
- for (const node of nodes) {
3030
- const ancestorId = typeof node.id === 'string' ? node.id : '';
3031
- if (!ancestorId || !ancestors.has(ancestorId)) continue;
3032
- if (String(node.type).endsWith('_trigger')) continue;
3033
- const execution = executionMap.get(ancestorId);
3034
- if (execution?.status !== 'COMPLETED') {
3035
- throw new BadRequestException(
3036
- `节点“${String(
3037
- node.label || node.type || ancestorId,
3038
- )}”没有可复用的成功结果`,
3039
- );
3040
- }
3041
- completedNodeIds.push(ancestorId);
3042
- outputs[ancestorId] = execution.output;
3043
- }
3044
-
3045
- const immediatePredecessors = mainPredecessors.get(retryFromNodeId) ?? [];
3046
- if (immediatePredecessors.length > 1) {
3047
- throw new BadRequestException('失败节点存在多个上游,暂不支持从这里重试');
3048
- }
2953
+ )
2954
+ return '请从外层循环或并行节点重试';
3049
2955
  if (
3050
- immediatePredecessors.length === 1 &&
3051
- !String(nodeMap.get(immediatePredecessors[0])?.type ?? '').endsWith(
3052
- '_trigger',
3053
- ) &&
3054
- !Object.prototype.hasOwnProperty.call(outputs, immediatePredecessors[0])
3055
- ) {
3056
- throw new BadRequestException('失败节点的上一步没有可复用结果');
3057
- }
3058
-
3059
- return { startNodeId: retryFromNodeId, completedNodeIds, outputs };
2956
+ !nodeExecutions.some(
2957
+ (execution) =>
2958
+ execution.nodeId === nodeId && execution.status === 'FAILED',
2959
+ )
2960
+ )
2961
+ return '只能重试当前失败的节点';
2962
+ return undefined;
3060
2963
  }
3061
2964
 
3062
- function listRetryableNodeIds(
2965
+ function listSameRunRetryableNodeIds(
3063
2966
  dsl: unknown,
3064
- nodeExecutions: Array<{
3065
- nodeId: string;
3066
- status: string;
3067
- output: unknown;
3068
- }>,
3069
- inheritedCheckpoint?: WorkflowRetryCheckpoint,
2967
+ nodeExecutions: Array<{ nodeId: string; status: string }>,
3070
2968
  ): string[] {
3071
2969
  return nodeExecutions
3072
2970
  .filter((execution) => execution.status === 'FAILED')
3073
- .filter((execution) => {
3074
- try {
3075
- buildRetryCheckpoint(
3076
- dsl,
3077
- nodeExecutions,
3078
- execution.nodeId,
3079
- inheritedCheckpoint,
3080
- );
3081
- return true;
3082
- } catch {
3083
- return false;
3084
- }
3085
- })
2971
+ .filter(
2972
+ (execution) =>
2973
+ !validateSameRunRetryTarget(dsl, nodeExecutions, execution.nodeId),
2974
+ )
3086
2975
  .map((execution) => execution.nodeId);
3087
2976
  }
3088
2977
 
@@ -10,11 +10,13 @@ describe('workflow route placement', () => {
10
10
  });
11
11
 
12
12
  describe('person and access routes', () => {
13
- it('keeps people, groups and access authorization as separate pages', () => {
14
- expect(getRoutePath('person_directory')).toBe('/admin/permission/people');
15
- expect(getRoutePath('people_group')).toBe(
16
- '/admin/permission/people-groups',
17
- );
13
+ it('merges service/connection/people pages into workflow settings', () => {
14
+ expect(getRoutePath('workflow_settings')).toBe('/admin/workflow/settings');
15
+ expect(getRoutePath('person_directory')).toBeNull();
16
+ expect(getRoutePath('people_group')).toBeNull();
17
+ expect(getRoutePath('workflow_temporal_tenant')).toBeNull();
18
+ expect(getRoutePath('workflow_connections')).toBeNull();
19
+ expect(getRoutePath('workflow_event_catalog')).toBeNull();
18
20
  expect(getRoutePath('user')).toBe('/admin/permission/user');
19
21
  });
20
22
  });
@@ -191,31 +191,13 @@ export const routeRegistry: Record<string, RouteEntry> = {
191
191
  })),
192
192
  ),
193
193
  },
194
- // 人员目录 — code: person_directory
195
- person_directory: {
196
- path: 'people',
197
- component: lazyWithReload(() =>
198
- import('routes/person-directory').then((m) => ({
199
- default: m.default,
200
- })),
201
- ),
202
- },
203
- // 访问授权 — 保留 user code/path,兼容既有页面权限和链接
194
+ // 用户管理 — 保留 user code/path,兼容既有页面权限和链接
204
195
  user: {
205
196
  path: 'user',
206
197
  component: lazyWithReload(() =>
207
198
  import('routes/user').then((m) => ({ default: m.UserListPage })),
208
199
  ),
209
200
  },
210
- // 人员组 — code: people_group
211
- people_group: {
212
- path: 'people-groups',
213
- component: lazyWithReload(() =>
214
- import('routes/people-groups').then((m) => ({
215
- default: m.default,
216
- })),
217
- ),
218
- },
219
201
  // 角色管理
220
202
  role: {
221
203
  path: 'role-list',
@@ -379,24 +361,12 @@ export const routeRegistry: Record<string, RouteEntry> = {
379
361
  () => import('routes/workflow/node-instances/show'),
380
362
  ),
381
363
  },
382
- workflow_temporal_tenant: {
383
- path: 'temporal-tenant',
384
- component: lazyWithReload(
385
- () => import('routes/workflow/temporal-tenant'),
386
- ),
387
- },
388
- workflow_connections: {
389
- path: 'connections',
390
- component: lazyWithReload(
391
- () => import('routes/workflow/connections'),
392
- ),
393
- },
394
- workflow_event_catalog: {
395
- path: 'business-events',
396
- component: lazyWithReload(
397
- () => import('routes/workflow/business-events'),
398
- ),
364
+ // 功能设置 — 合并原服务注册/连接管理/人员目录/人员组四个页面
365
+ workflow_settings: {
366
+ path: 'settings',
367
+ component: lazyWithReload(() => import('routes/workflow/settings')),
399
368
  },
369
+ // 业务事件运行记录详情(列表已并入功能设置 tab,此路由保留详情深链)
400
370
  businessEventDetail: {
401
371
  path: 'business-events/show/:registrationCode',
402
372
  component: lazyWithReload(