@gadmin2n/schematics 0.0.140 → 0.0.141

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 (46) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/config/ui/Event.ts +1 -1
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +1 -1
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/gen-jwt.ts +8 -1
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +32 -4
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +39 -33
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.spec.ts +34 -1
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +26 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +149 -0
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +20 -6
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.spec.ts +85 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.ts +157 -0
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +59 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +15 -4
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +125 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +109 -2
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/PageHeader.tsx +46 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/index.ts +1 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts +10 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +6 -4
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/constants/layout.ts +30 -2
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +20 -1
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +20 -1
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +9 -20
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/audit/list.tsx +141 -138
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +8 -19
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/inbox.tsx +12 -19
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +47 -150
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +13 -12
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +110 -113
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +61 -64
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +187 -200
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/approval-channels/index.tsx +46 -44
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NewNodeDefaults.test.tsx +6 -2
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +50 -41
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +9 -22
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instances.tsx +4 -8
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/index.tsx +9 -22
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.test.tsx +42 -0
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.tsx +77 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.test.tsx +29 -1
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.tsx +245 -55
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.test.tsx +152 -0
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +417 -161
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +5 -1
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/styles/index.css +20 -0
  46. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ type DslNode = {
2
+ id?: unknown;
3
+ type?: unknown;
4
+ config?: unknown;
5
+ };
6
+
7
+ type DslEdge = {
8
+ source?: unknown;
9
+ target?: unknown;
10
+ kind?: unknown;
11
+ pairId?: unknown;
12
+ };
13
+
14
+ type DslLike = {
15
+ nodes?: DslNode[];
16
+ edges?: DslEdge[];
17
+ };
18
+
19
+ export function validateWebhookResponseWorkflow(dsl: unknown): void {
20
+ const graph = asDsl(dsl);
21
+ const nodes = graph.nodes ?? [];
22
+ const edges = (graph.edges ?? []).filter(
23
+ (edge) => (!edge.kind || edge.kind === 'FLOW') && !edge.pairId,
24
+ );
25
+ const waitingTriggers = nodes.filter(
26
+ (node) =>
27
+ node.type === 'webhook_trigger' &&
28
+ asRecord(node.config).responseMode === 'WAIT_FOR_RESULT',
29
+ );
30
+
31
+ for (const trigger of waitingTriggers) {
32
+ const config = asRecord(trigger.config);
33
+ const timeout = config.responseTimeoutSeconds ?? 60;
34
+ if (
35
+ typeof timeout !== 'number' ||
36
+ !Number.isInteger(timeout) ||
37
+ timeout < 1 ||
38
+ timeout > 300
39
+ ) {
40
+ throw new Error('等待返回结果的超时时间必须是 1 到 300 秒');
41
+ }
42
+ validateReturningBranches(nodes, edges, String(trigger.id));
43
+ }
44
+ }
45
+
46
+ function validateReturningBranches(
47
+ nodes: DslNode[],
48
+ edges: DslEdge[],
49
+ triggerId: string,
50
+ ): void {
51
+ const nodeById = new Map(
52
+ nodes
53
+ .filter((node) => typeof node.id === 'string')
54
+ .map((node) => [String(node.id), node]),
55
+ );
56
+ const outgoing = new Map<string, string[]>();
57
+ const incoming = new Map<string, string[]>();
58
+ for (const edge of edges) {
59
+ if (typeof edge.source !== 'string' || typeof edge.target !== 'string')
60
+ continue;
61
+ const targets = outgoing.get(edge.source) ?? [];
62
+ targets.push(edge.target);
63
+ outgoing.set(edge.source, targets);
64
+ const sources = incoming.get(edge.target) ?? [];
65
+ sources.push(edge.source);
66
+ incoming.set(edge.target, sources);
67
+ }
68
+
69
+ const queue = [...(outgoing.get(triggerId) ?? [])];
70
+ const visited = new Set<string>();
71
+ if (queue.length === 0) {
72
+ throw new Error('等待返回结果时,每个流程分支都必须以“返回结果”结束');
73
+ }
74
+
75
+ while (queue.length > 0) {
76
+ const nodeId = queue.shift()!;
77
+ if (visited.has(nodeId)) continue;
78
+ visited.add(nodeId);
79
+ const node = nodeById.get(nodeId);
80
+ if (!node) continue;
81
+ const next = outgoing.get(nodeId) ?? [];
82
+
83
+ if (node.type === 'return_result') {
84
+ validateReturnNode(node, next, incoming.get(nodeId) ?? [], nodeById);
85
+ continue;
86
+ }
87
+ if (next.length === 0) {
88
+ throw new Error('等待返回结果时,每个流程分支都必须以“返回结果”结束');
89
+ }
90
+ queue.push(...next);
91
+ }
92
+ }
93
+
94
+ function validateReturnNode(
95
+ node: DslNode,
96
+ next: string[],
97
+ previous: string[],
98
+ nodeById: Map<string, DslNode>,
99
+ ): void {
100
+ const config = asRecord(node.config);
101
+ if (config.resultType !== 'JSON') {
102
+ throw new Error('HTTP 调用只能返回普通数据');
103
+ }
104
+ if (next.length > 0) {
105
+ throw new Error('“返回结果”必须是流程分支的最后一个节点');
106
+ }
107
+ if (config.sourceMode === 'INPUT') {
108
+ const processingNodes = previous.filter((nodeId) => {
109
+ const predecessorType = nodeById.get(nodeId)?.type;
110
+ return (
111
+ typeof predecessorType !== 'string' ||
112
+ !predecessorType.endsWith('_trigger')
113
+ );
114
+ });
115
+ if (processingNodes.length !== 1) {
116
+ throw new Error('返回上一步结果时,“返回结果”前需要连接一个处理节点');
117
+ }
118
+ } else if (config.sourceMode === 'WORKFLOW') {
119
+ if (typeof config.path !== 'string' || !config.path.trim()) {
120
+ throw new Error('返回 Workflow 数据时必须填写字段路径');
121
+ }
122
+ } else {
123
+ throw new Error('返回结果来源必须是上一个节点或 Workflow 数据');
124
+ }
125
+ if (
126
+ config.httpStatusCode !== undefined &&
127
+ (typeof config.httpStatusCode !== 'number' ||
128
+ !Number.isInteger(config.httpStatusCode) ||
129
+ config.httpStatusCode < 100 ||
130
+ config.httpStatusCode > 599)
131
+ ) {
132
+ throw new Error('HTTP 状态码必须是 100 到 599 的整数');
133
+ }
134
+ if (config.httpHeaders !== undefined) {
135
+ const headers = asRecord(config.httpHeaders);
136
+ if (
137
+ !config.httpHeaders ||
138
+ typeof config.httpHeaders !== 'object' ||
139
+ Array.isArray(config.httpHeaders) ||
140
+ Object.values(headers).some((value) => typeof value !== 'string')
141
+ ) {
142
+ throw new Error('HTTP 响应头必须是字符串键值');
143
+ }
144
+ }
145
+ }
146
+
147
+ function asDsl(value: unknown): DslLike {
148
+ return value && typeof value === 'object' && !Array.isArray(value)
149
+ ? (value as DslLike)
150
+ : {};
151
+ }
152
+
153
+ function asRecord(value: unknown): Record<string, unknown> {
154
+ return value && typeof value === 'object' && !Array.isArray(value)
155
+ ? (value as Record<string, unknown>)
156
+ : {};
157
+ }
@@ -0,0 +1,59 @@
1
+ import { WorkflowController } from './workflow.controller';
2
+
3
+ describe('WorkflowController — webhook HTTP response', () => {
4
+ it('returns the Workflow body with its configured status and headers', async () => {
5
+ const workflowService = {
6
+ receiveWebhook: jest.fn().mockResolvedValue({
7
+ instanceIds: ['42'],
8
+ response: {
9
+ statusCode: 201,
10
+ headers: {
11
+ 'x-workflow-result': 'created',
12
+ },
13
+ body: {
14
+ ticketId: 'T-42',
15
+ },
16
+ },
17
+ }),
18
+ };
19
+ const controller = new WorkflowController(
20
+ workflowService as never,
21
+ {} as never,
22
+ {} as never,
23
+ );
24
+ const response = {
25
+ statusCode: 200,
26
+ headers: {} as Record<string, string>,
27
+ status(code: number) {
28
+ this.statusCode = code;
29
+ return this;
30
+ },
31
+ set(headers: Record<string, string>) {
32
+ this.headers = headers;
33
+ return this;
34
+ },
35
+ };
36
+
37
+ const body = await controller.receiveWebhook(
38
+ 'create-ticket',
39
+ undefined as never,
40
+ undefined as never,
41
+ {
42
+ method: 'POST',
43
+ rawBody: Buffer.from('{}'),
44
+ headers: {},
45
+ } as never,
46
+ {},
47
+ {},
48
+ response as never,
49
+ );
50
+
51
+ expect(response.statusCode).toBe(201);
52
+ expect(response.headers).toEqual({
53
+ 'x-workflow-result': 'created',
54
+ });
55
+ expect(body).toEqual({
56
+ ticketId: 'T-42',
57
+ });
58
+ });
59
+ });
@@ -12,11 +12,12 @@ import {
12
12
  Query,
13
13
  RawBodyRequest,
14
14
  Req,
15
+ Res,
15
16
  UnauthorizedException,
16
17
  UseGuards,
17
18
  } from '@nestjs/common';
18
19
  import { ApiTags } from '@nestjs/swagger';
19
- import { Request } from 'express';
20
+ import { Request, Response } from 'express';
20
21
  import AuthGuard, { AllowUnauthorizedRequest } from '../../lib/auth.guard';
21
22
  import { TemporalService } from './temporal.service';
22
23
  import {
@@ -253,7 +254,8 @@ export class WorkflowController {
253
254
  * Request verification is configured by the matching trigger. Existing triggers
254
255
  * require HMAC-SHA256; direct triggers accept callers that cannot sign requests.
255
256
  *
256
- * Returns 202 Accepted with the list of created instance IDs (workflow runs async).
257
+ * By default returns 202 Accepted with the created instance IDs. A trigger in
258
+ * function mode keeps this request open and returns the Workflow result instead.
257
259
  */
258
260
  @Post('webhook/:path')
259
261
  @HttpCode(202)
@@ -265,8 +267,9 @@ export class WorkflowController {
265
267
  @Req() req: RawBodyRequest<Request>,
266
268
  @Body() body: any,
267
269
  @Query() query: any,
268
- ): Promise<{ instanceIds: string[] }> {
269
- return this.workflowService.receiveWebhook({
270
+ @Res({ passthrough: true }) response: Response,
271
+ ): Promise<unknown> {
272
+ const result = await this.workflowService.receiveWebhook({
270
273
  path,
271
274
  method: req.method,
272
275
  signatureHeader: signature,
@@ -276,5 +279,13 @@ export class WorkflowController {
276
279
  headers: req.headers as Record<string, any>,
277
280
  query,
278
281
  });
282
+ if (!result.response) {
283
+ response.status(202);
284
+ return { instanceIds: result.instanceIds };
285
+ }
286
+
287
+ response.status(result.response.statusCode);
288
+ response.set(result.response.headers);
289
+ return result.response.body;
279
290
  }
280
291
  }
@@ -617,6 +617,7 @@ describe('WorkflowService — receiveWebhook', () => {
617
617
  };
618
618
  temporal = {
619
619
  startWorkflow: jest.fn(async () => ({ temporalRunId: 'run-1' })),
620
+ waitForWorkflowResult: jest.fn(),
620
621
  };
621
622
 
622
623
  const module: TestingModule = await Test.createTestingModule({
@@ -648,6 +649,8 @@ describe('WorkflowService — receiveWebhook', () => {
648
649
  secret = SECRET,
649
650
  path = PATH,
650
651
  verificationMode?: 'HMAC' | 'NONE',
652
+ responseMode?: 'IMMEDIATE' | 'WAIT_FOR_RESULT',
653
+ responseTimeoutSeconds?: number,
651
654
  ) => ({
652
655
  id: BigInt(id),
653
656
  status: 'PUBLISHED',
@@ -667,6 +670,8 @@ describe('WorkflowService — receiveWebhook', () => {
667
670
  method: METHOD,
668
671
  secret,
669
672
  ...(verificationMode && { verificationMode }),
673
+ ...(responseMode && { responseMode }),
674
+ ...(responseTimeoutSeconds && { responseTimeoutSeconds }),
670
675
  },
671
676
  },
672
677
  ],
@@ -825,6 +830,126 @@ describe('WorkflowService — receiveWebhook', () => {
825
830
  });
826
831
  });
827
832
 
833
+ it('waits for the Workflow return result when the trigger uses function mode', async () => {
834
+ prisma.workflow.findMany.mockResolvedValueOnce([
835
+ mkPublishedWorkflow(1, '', PATH, 'NONE', 'WAIT_FOR_RESULT', 45),
836
+ ]);
837
+ mkExecuteSetup();
838
+ temporal.waitForWorkflowResult.mockResolvedValueOnce({
839
+ kind: 'COMPLETED',
840
+ result: {
841
+ status: 'COMPLETED',
842
+ output: {
843
+ ticketId: 'T-42',
844
+ accepted: true,
845
+ },
846
+ httpResponse: {
847
+ statusCode: 201,
848
+ headers: {
849
+ 'x-workflow-result': 'created',
850
+ },
851
+ },
852
+ },
853
+ });
854
+
855
+ const result = await service.receiveWebhook({
856
+ path: PATH,
857
+ method: METHOD,
858
+ signatureHeader: undefined,
859
+ timestampHeader: undefined,
860
+ rawBody: RAW,
861
+ body: { orderId: 42 },
862
+ headers: {},
863
+ query: {},
864
+ });
865
+
866
+ expect(result).toEqual({
867
+ instanceIds: ['7777'],
868
+ response: {
869
+ statusCode: 201,
870
+ headers: {
871
+ 'x-workflow-result': 'created',
872
+ },
873
+ body: {
874
+ ticketId: 'T-42',
875
+ accepted: true,
876
+ },
877
+ },
878
+ });
879
+ expect(temporal.waitForWorkflowResult).toHaveBeenCalledWith(
880
+ 'run-1',
881
+ 45_000,
882
+ );
883
+ });
884
+
885
+ it('returns a gateway timeout without cancelling a still-running Workflow', async () => {
886
+ prisma.workflow.findMany.mockResolvedValueOnce([
887
+ mkPublishedWorkflow(1, '', PATH, 'NONE', 'WAIT_FOR_RESULT', 60),
888
+ ]);
889
+ mkExecuteSetup();
890
+ temporal.waitForWorkflowResult.mockResolvedValueOnce({
891
+ kind: 'TIMED_OUT',
892
+ });
893
+
894
+ const result = await service.receiveWebhook({
895
+ path: PATH,
896
+ method: METHOD,
897
+ signatureHeader: undefined,
898
+ timestampHeader: undefined,
899
+ rawBody: RAW,
900
+ body: {},
901
+ headers: {},
902
+ query: {},
903
+ });
904
+
905
+ expect(result).toEqual({
906
+ instanceIds: ['7777'],
907
+ response: {
908
+ statusCode: 504,
909
+ headers: {},
910
+ body: {
911
+ code: 'WORKFLOW_RESPONSE_TIMEOUT',
912
+ message: 'Workflow did not return a result within 60 seconds',
913
+ instanceId: '7777',
914
+ },
915
+ },
916
+ });
917
+ });
918
+
919
+ it('returns an internal error response when the Workflow execution fails', async () => {
920
+ prisma.workflow.findMany.mockResolvedValueOnce([
921
+ mkPublishedWorkflow(1, '', PATH, 'NONE', 'WAIT_FOR_RESULT'),
922
+ ]);
923
+ mkExecuteSetup();
924
+ temporal.waitForWorkflowResult.mockRejectedValueOnce(
925
+ new Error('private Temporal failure'),
926
+ );
927
+
928
+ const result = await service.receiveWebhook({
929
+ path: PATH,
930
+ method: METHOD,
931
+ signatureHeader: undefined,
932
+ timestampHeader: undefined,
933
+ rawBody: RAW,
934
+ body: {},
935
+ headers: {},
936
+ query: {},
937
+ });
938
+
939
+ expect(result).toEqual({
940
+ instanceIds: ['7777'],
941
+ response: {
942
+ statusCode: 500,
943
+ headers: {},
944
+ body: {
945
+ code: 'WORKFLOW_EXECUTION_FAILED',
946
+ message: 'Workflow execution failed',
947
+ instanceId: '7777',
948
+ },
949
+ },
950
+ });
951
+ });
952
+
828
953
  it('rejects with 401 when one of multiple matches has a different secret', async () => {
829
954
  // Strict mode: every match must verify against the same signature
830
955
  prisma.workflow.findMany.mockResolvedValueOnce([
@@ -53,6 +53,8 @@ export class WorkflowService {
53
53
  method: string;
54
54
  secret?: string;
55
55
  verificationMode: 'HMAC' | 'NONE';
56
+ responseMode: 'IMMEDIATE' | 'WAIT_FOR_RESULT';
57
+ responseTimeoutSeconds: number;
56
58
  }> {
57
59
  if (!Array.isArray(dsl?.nodes)) return [];
58
60
  return dsl.nodes
@@ -66,6 +68,16 @@ export class WorkflowService {
66
68
  secret: n.config.secret,
67
69
  verificationMode:
68
70
  n.config.verificationMode === 'NONE' ? 'NONE' : 'HMAC',
71
+ responseMode:
72
+ n.config.responseMode === 'WAIT_FOR_RESULT'
73
+ ? 'WAIT_FOR_RESULT'
74
+ : 'IMMEDIATE',
75
+ responseTimeoutSeconds:
76
+ Number.isInteger(n.config.responseTimeoutSeconds) &&
77
+ n.config.responseTimeoutSeconds >= 1 &&
78
+ n.config.responseTimeoutSeconds <= 300
79
+ ? n.config.responseTimeoutSeconds
80
+ : 60,
69
81
  }));
70
82
  }
71
83
 
@@ -806,7 +818,14 @@ export class WorkflowService {
806
818
  body: any;
807
819
  headers: Record<string, any>;
808
820
  query: Record<string, any>;
809
- }): Promise<{ instanceIds: string[] }> {
821
+ }): Promise<{
822
+ instanceIds: string[];
823
+ response?: {
824
+ statusCode: number;
825
+ headers: Record<string, string>;
826
+ body: unknown;
827
+ };
828
+ }> {
810
829
  // 1) Lookup matching published + enabled workflows. Verification is configured
811
830
  // per trigger, so the matching trigger must be known before headers are checked.
812
831
  const published = await this.prisma.workflow.findMany({
@@ -822,6 +841,8 @@ export class WorkflowService {
822
841
  dsl: any;
823
842
  secret?: string;
824
843
  verificationMode: 'HMAC' | 'NONE';
844
+ responseMode: 'IMMEDIATE' | 'WAIT_FOR_RESULT';
845
+ responseTimeoutSeconds: number;
825
846
  };
826
847
  const matches: Match[] = [];
827
848
  for (const wf of published) {
@@ -840,6 +861,8 @@ export class WorkflowService {
840
861
  dsl,
841
862
  secret: cfg.secret,
842
863
  verificationMode: cfg.verificationMode,
864
+ responseMode: cfg.responseMode,
865
+ responseTimeoutSeconds: cfg.responseTimeoutSeconds,
843
866
  });
844
867
  }
845
868
 
@@ -896,7 +919,68 @@ export class WorkflowService {
896
919
  ),
897
920
  );
898
921
 
899
- return { instanceIds: results.map((r) => r.instanceId) };
922
+ const instanceIds = results.map((result) => result.instanceId);
923
+ const waitingIndex = matches.findIndex(
924
+ (match) => match.responseMode === 'WAIT_FOR_RESULT',
925
+ );
926
+ if (waitingIndex === -1) return { instanceIds };
927
+
928
+ const match = matches[waitingIndex];
929
+ const execution = results[waitingIndex];
930
+ try {
931
+ const waited = await this.temporalService.waitForWorkflowResult(
932
+ execution.temporalRunId,
933
+ match.responseTimeoutSeconds * 1000,
934
+ );
935
+ if (waited.kind === 'TIMED_OUT') {
936
+ return {
937
+ instanceIds,
938
+ response: {
939
+ statusCode: 504,
940
+ headers: {},
941
+ body: {
942
+ code: 'WORKFLOW_RESPONSE_TIMEOUT',
943
+ message: `Workflow did not return a result within ${match.responseTimeoutSeconds} seconds`,
944
+ instanceId: execution.instanceId,
945
+ },
946
+ },
947
+ };
948
+ }
949
+
950
+ const workflowResult = asRecord(waited.result);
951
+ if (
952
+ workflowResult.status !== 'COMPLETED' ||
953
+ !Object.prototype.hasOwnProperty.call(workflowResult, 'output')
954
+ ) {
955
+ throw new Error('Workflow completed without a return result');
956
+ }
957
+ const httpResponse = asRecord(workflowResult.httpResponse);
958
+ return {
959
+ instanceIds,
960
+ response: {
961
+ statusCode: normalizeHttpStatusCode(httpResponse.statusCode),
962
+ headers: normalizeHttpHeaders(httpResponse.headers),
963
+ body: workflowResult.output,
964
+ },
965
+ };
966
+ } catch (error) {
967
+ this.logger.error(
968
+ `Workflow ${execution.instanceId} failed while waiting for an HTTP response`,
969
+ error instanceof Error ? error.stack : String(error),
970
+ );
971
+ return {
972
+ instanceIds,
973
+ response: {
974
+ statusCode: 500,
975
+ headers: {},
976
+ body: {
977
+ code: 'WORKFLOW_EXECUTION_FAILED',
978
+ message: 'Workflow execution failed',
979
+ instanceId: execution.instanceId,
980
+ },
981
+ },
982
+ };
983
+ }
900
984
  }
901
985
 
902
986
  async getInstanceDetail(instanceId: bigint) {
@@ -1038,3 +1122,26 @@ function createWorkflowExecutionState(
1038
1122
  nodes: {},
1039
1123
  };
1040
1124
  }
1125
+
1126
+ function asRecord(value: unknown): Record<string, unknown> {
1127
+ return value && typeof value === 'object' && !Array.isArray(value)
1128
+ ? (value as Record<string, unknown>)
1129
+ : {};
1130
+ }
1131
+
1132
+ function normalizeHttpStatusCode(value: unknown): number {
1133
+ return typeof value === 'number' &&
1134
+ Number.isInteger(value) &&
1135
+ value >= 100 &&
1136
+ value <= 599
1137
+ ? value
1138
+ : 200;
1139
+ }
1140
+
1141
+ function normalizeHttpHeaders(value: unknown): Record<string, string> {
1142
+ return Object.fromEntries(
1143
+ Object.entries(asRecord(value)).filter(
1144
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
1145
+ ),
1146
+ );
1147
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { Card, Typography } from 'antd';
3
+ import { HEADER_CARD_PROPS } from 'constants/layout';
4
+
5
+ interface PageHeaderProps {
6
+ /** 页面标题(Title level 4) */
7
+ title: React.ReactNode;
8
+ /** 标题下方 12px 辅助说明,可选 */
9
+ subtitle?: React.ReactNode;
10
+ /** 右侧操作区(按钮组),可选 */
11
+ extra?: React.ReactNode;
12
+ }
13
+
14
+ /**
15
+ * 自定义页面统一的头部卡片:标题 + 副标题 + 右侧操作按钮。
16
+ * 样式规范见 page-create-new skill(Header Card 压扁、Title level 4、副标题 12px)。
17
+ */
18
+ export const PageHeader: React.FC<PageHeaderProps> = ({
19
+ title,
20
+ subtitle,
21
+ extra,
22
+ }) => (
23
+ <Card {...HEADER_CARD_PROPS}>
24
+ <div
25
+ style={{
26
+ display: 'flex',
27
+ justifyContent: 'space-between',
28
+ alignItems: 'center',
29
+ flexWrap: 'wrap',
30
+ gap: 8,
31
+ }}
32
+ >
33
+ <div>
34
+ <Typography.Title level={4} style={{ margin: 0 }}>
35
+ {title}
36
+ </Typography.Title>
37
+ {subtitle && (
38
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
39
+ {subtitle}
40
+ </Typography.Text>
41
+ )}
42
+ </div>
43
+ {extra}
44
+ </div>
45
+ </Card>
46
+ );
@@ -2,6 +2,7 @@ export * from './SearchBar';
2
2
  export * from './BulkActions';
3
3
  export * from './RowActions';
4
4
  export * from './ListPageHeader';
5
+ export * from './PageHeader';
5
6
  export * from './custom-avatar';
6
7
  export * from './pagination-total';
7
8
  export * from './tags';
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRoutePath } from './routeRegistry';
3
+
4
+ describe('workflow route placement', () => {
5
+ it('places approval tasks inside Workflow', () => {
6
+ expect(getRoutePath('approval_tasks')).toBe(
7
+ '/admin/workflow/approval-tasks',
8
+ );
9
+ });
10
+ });
@@ -231,10 +231,6 @@ export const routeRegistry: Record<string, RouteEntry> = {
231
231
  path: 'data-mngt',
232
232
  children: buildDataMngtChildren(),
233
233
  },
234
- approval_tasks: {
235
- path: 'approval-tasks',
236
- component: lazyWithReload(() => import('routes/human-approval/inbox')),
237
- },
238
234
  // Canvas
239
235
  canvas: {
240
236
  path: 'canvas',
@@ -383,6 +379,12 @@ export const routeRegistry: Record<string, RouteEntry> = {
383
379
  () => import('routes/workflow/approval-channels'),
384
380
  ),
385
381
  },
382
+ approval_tasks: {
383
+ path: 'approval-tasks',
384
+ component: lazyWithReload(
385
+ () => import('routes/human-approval/inbox'),
386
+ ),
387
+ },
386
388
  },
387
389
  },
388
390
  // 操作日志 — sortOrder 1070, code: admin.audit