@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.
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/ui/Event.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/gen-jwt.ts +8 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +32 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +39 -33
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.spec.ts +34 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +26 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +149 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +20 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.spec.ts +85 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-http-response.ts +157 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.spec.ts +59 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +15 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +125 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +109 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/PageHeader.tsx +46 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/index.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts +10 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.tsx +6 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/constants/layout.ts +30 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +20 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +20 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agenda/index.tsx +9 -20
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/audit/list.tsx +141 -138
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +8 -19
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/human-approval/inbox.tsx +12 -19
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +47 -150
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +13 -12
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +110 -113
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +61 -64
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +187 -200
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/approval-channels/index.tsx +46 -44
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NewNodeDefaults.test.tsx +6 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/connections/index.tsx +50 -41
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/index.tsx +9 -22
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instances.tsx +4 -8
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/index.tsx +9 -22
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.test.tsx +42 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ExternalRequestConfigEditor.tsx +77 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.test.tsx +29 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/ReturnResultConfigEditor.tsx +245 -55
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.test.tsx +152 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/temporal-tenant/index.tsx +417 -161
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +5 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/styles/index.css +20 -0
- 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
|
-
*
|
|
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
|
-
|
|
269
|
-
|
|
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<{
|
|
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
|
-
|
|
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
|
+
);
|
package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/config/routeRegistry.test.ts
ADDED
|
@@ -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
|