@gadmin2n/schematics 0.0.140 → 0.0.142
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
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"devDependencies": {
|
|
95
95
|
"@faker-js/faker": "^10.4.0",
|
|
96
96
|
"@gadmin2n/prisma-nest-generator": "^0.0.49",
|
|
97
|
-
"@gadmin2n/prisma-react-generator": "^0.0.
|
|
97
|
+
"@gadmin2n/prisma-react-generator": "^0.0.73",
|
|
98
98
|
"@nestjs/cli": "^11.0.21",
|
|
99
99
|
"@nestjs/testing": "^10.4.15",
|
|
100
100
|
"@swc/cli": "^0.8.1",
|
|
@@ -49,7 +49,14 @@ async function main() {
|
|
|
49
49
|
process.exit(1);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const ttl = expiresIn ? parseInt(expiresIn, 10) :
|
|
52
|
+
const ttl = expiresIn ? parseInt(expiresIn, 10) : 3600; // 默认 1 小时(单位:秒)
|
|
53
|
+
|
|
54
|
+
if (Number.isNaN(ttl) || ttl <= 0) {
|
|
55
|
+
console.error(
|
|
56
|
+
`[gen-jwt] --expiresIn must be a positive integer (seconds), got "${expiresIn}".`,
|
|
57
|
+
);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
53
60
|
|
|
54
61
|
const token = await TaiHuId.sign(
|
|
55
62
|
{
|
package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts
CHANGED
|
@@ -124,6 +124,21 @@ const INLINE_NODE_DEFINITIONS: BaseNodeTypeSeed[] = [
|
|
|
124
124
|
description:
|
|
125
125
|
'HMAC-SHA256 secret. Caller must send X-Workflow-Signature: sha256=HEX(HMAC(secret, "{ts}.{rawBody}")) and X-Workflow-Timestamp.',
|
|
126
126
|
},
|
|
127
|
+
responseMode: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
enum: ['IMMEDIATE', 'WAIT_FOR_RESULT'],
|
|
130
|
+
default: 'IMMEDIATE',
|
|
131
|
+
description:
|
|
132
|
+
'Immediately acknowledge the request or wait for a Return Result node.',
|
|
133
|
+
},
|
|
134
|
+
responseTimeoutSeconds: {
|
|
135
|
+
type: 'integer',
|
|
136
|
+
minimum: 1,
|
|
137
|
+
maximum: 300,
|
|
138
|
+
default: 60,
|
|
139
|
+
description:
|
|
140
|
+
'Maximum time to keep the HTTP request open in WAIT_FOR_RESULT mode.',
|
|
141
|
+
},
|
|
127
142
|
},
|
|
128
143
|
required: ['path', 'method'],
|
|
129
144
|
allOf: [
|
|
@@ -1007,8 +1022,8 @@ const returnResultNode: NodeTypeSeed = {
|
|
|
1007
1022
|
resultType: {
|
|
1008
1023
|
type: 'string',
|
|
1009
1024
|
title: '结果类型',
|
|
1010
|
-
enum: ['APPROVAL_SUBJECTS'],
|
|
1011
|
-
default: '
|
|
1025
|
+
enum: ['JSON', 'APPROVAL_SUBJECTS'],
|
|
1026
|
+
default: 'JSON',
|
|
1012
1027
|
},
|
|
1013
1028
|
sourceMode: {
|
|
1014
1029
|
type: 'string',
|
|
@@ -1021,6 +1036,19 @@ const returnResultNode: NodeTypeSeed = {
|
|
|
1021
1036
|
title: 'Workflow 数据字段',
|
|
1022
1037
|
description: '例如 approvers',
|
|
1023
1038
|
},
|
|
1039
|
+
httpStatusCode: {
|
|
1040
|
+
type: 'integer',
|
|
1041
|
+
title: 'HTTP 状态码',
|
|
1042
|
+
minimum: 100,
|
|
1043
|
+
maximum: 599,
|
|
1044
|
+
default: 200,
|
|
1045
|
+
},
|
|
1046
|
+
httpHeaders: {
|
|
1047
|
+
type: 'object',
|
|
1048
|
+
title: 'HTTP 响应头',
|
|
1049
|
+
additionalProperties: { type: 'string' },
|
|
1050
|
+
default: {},
|
|
1051
|
+
},
|
|
1024
1052
|
},
|
|
1025
1053
|
required: ['resultType', 'sourceMode'],
|
|
1026
1054
|
},
|
|
@@ -1028,7 +1056,7 @@ const returnResultNode: NodeTypeSeed = {
|
|
|
1028
1056
|
outputSchema: { type: 'object' },
|
|
1029
1057
|
outputs: [],
|
|
1030
1058
|
runtimeKey: 'return_result',
|
|
1031
|
-
schemaVersion:
|
|
1059
|
+
schemaVersion: 4,
|
|
1032
1060
|
instancePolicy: 'INLINE_ONLY',
|
|
1033
1061
|
uiSchema: { widget: 'return-result-config' },
|
|
1034
1062
|
};
|
|
@@ -1203,7 +1231,7 @@ const webhookDefinition = INLINE_NODE_DEFINITIONS.find(
|
|
|
1203
1231
|
const webhookNode: NodeTypeSeed = {
|
|
1204
1232
|
...inlineNode(webhookDefinition),
|
|
1205
1233
|
runtimeKey: 'webhook_trigger',
|
|
1206
|
-
schemaVersion:
|
|
1234
|
+
schemaVersion: 4,
|
|
1207
1235
|
uiSchema: { widget: 'external-request-config' },
|
|
1208
1236
|
};
|
|
1209
1237
|
|
|
@@ -12,7 +12,7 @@ import { seedNodeTypes } from './workflow-node-types';
|
|
|
12
12
|
* Workflow 模块 seed
|
|
13
13
|
*
|
|
14
14
|
* 一站式负责 workflow 模块所有 seed:
|
|
15
|
-
* 1. 菜单结构:workflow
|
|
15
|
+
* 1. 菜单结构:workflow 父分组及其管理、审批任务页面
|
|
16
16
|
* 2. resource: workflow / workflowNodeInstance
|
|
17
17
|
* 3. PageResource 绑定与 SYSTEM_ADMIN 授权
|
|
18
18
|
* 4. 业务数据:node-types / workflows / event-trigger function & index
|
|
@@ -41,30 +41,6 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
|
|
|
41
41
|
});
|
|
42
42
|
console.log(`[workflow] Page: ${workflowPage.code} (id=${workflowPage.id})`);
|
|
43
43
|
|
|
44
|
-
// Approval work belongs to the user's task inbox, not Workflow management.
|
|
45
|
-
const approvalTasksPage = await upsertPage(prisma, {
|
|
46
|
-
code: 'approval_tasks',
|
|
47
|
-
name: 'approval_tasks',
|
|
48
|
-
zhName: '审批任务',
|
|
49
|
-
enName: 'Approval Tasks',
|
|
50
|
-
sortOrder: 1040,
|
|
51
|
-
parentId: admin.id,
|
|
52
|
-
});
|
|
53
|
-
await bindResourceToPage(
|
|
54
|
-
prisma,
|
|
55
|
-
approvalTasksPage.id,
|
|
56
|
-
'humanApproval',
|
|
57
|
-
'Read and decide assigned Human Approval tasks',
|
|
58
|
-
);
|
|
59
|
-
for (const eligibleRole of await prisma.role.findMany()) {
|
|
60
|
-
await grantRoleAccess(
|
|
61
|
-
prisma,
|
|
62
|
-
eligibleRole.id,
|
|
63
|
-
approvalTasksPage.id,
|
|
64
|
-
await getPageResourceIds(prisma, approvalTasksPage.id),
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
44
|
// ── leaf pages + resource 绑定 ─────────────────────────────────────────────
|
|
69
45
|
const leafDefs: {
|
|
70
46
|
code: string;
|
|
@@ -115,19 +91,33 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
|
|
|
115
91
|
resourceDescription:
|
|
116
92
|
'Reusable dispatch and callback workflows for external approvals',
|
|
117
93
|
},
|
|
94
|
+
{
|
|
95
|
+
code: 'approval_tasks',
|
|
96
|
+
zhName: '审批任务',
|
|
97
|
+
enName: 'Approval Tasks',
|
|
98
|
+
sortOrder: 6,
|
|
99
|
+
resourceCode: 'humanApproval',
|
|
100
|
+
resourceDescription: 'Read and decide assigned Human Approval tasks',
|
|
101
|
+
},
|
|
118
102
|
];
|
|
119
103
|
|
|
120
104
|
const allPages = [{ id: workflowPage.id, code: workflowPage.code }];
|
|
121
105
|
|
|
122
106
|
for (const def of leafDefs) {
|
|
123
|
-
const p = await upsertPage(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
107
|
+
const p = await upsertPage(
|
|
108
|
+
prisma,
|
|
109
|
+
{
|
|
110
|
+
code: def.code,
|
|
111
|
+
name: def.code,
|
|
112
|
+
zhName: def.zhName,
|
|
113
|
+
enName: def.enName,
|
|
114
|
+
sortOrder: def.sortOrder,
|
|
115
|
+
parentId: workflowPage.id,
|
|
116
|
+
},
|
|
117
|
+
def.code === 'approval_tasks'
|
|
118
|
+
? { parentId: workflowPage.id, sortOrder: def.sortOrder }
|
|
119
|
+
: {},
|
|
120
|
+
);
|
|
131
121
|
allPages.push({ id: p.id, code: p.code });
|
|
132
122
|
console.log(`[workflow] Page: ${p.code} (id=${p.id})`);
|
|
133
123
|
|
|
@@ -169,6 +159,22 @@ export async function seedWorkflowModule(prisma: PrismaClient): Promise<void> {
|
|
|
169
159
|
);
|
|
170
160
|
}
|
|
171
161
|
|
|
162
|
+
// 审批任务是所有业务角色的个人待办入口;移动到 Workflow 下不改变权限。
|
|
163
|
+
const approvalTasksPage = allPages.find(
|
|
164
|
+
(page) => page.code === 'approval_tasks',
|
|
165
|
+
);
|
|
166
|
+
if (approvalTasksPage) {
|
|
167
|
+
const resourceIds = await getPageResourceIds(prisma, approvalTasksPage.id);
|
|
168
|
+
for (const eligibleRole of await prisma.role.findMany()) {
|
|
169
|
+
await grantRoleAccess(
|
|
170
|
+
prisma,
|
|
171
|
+
eligibleRole.id,
|
|
172
|
+
approvalTasksPage.id,
|
|
173
|
+
resourceIds,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
172
178
|
// Connection management is part of creating and maintaining reusable Node
|
|
173
179
|
// Templates. Keep its page access aligned with the existing Node Template
|
|
174
180
|
// page instead of limiting it to SYSTEM_ADMIN on installations that already
|
|
@@ -13,6 +13,10 @@ const mockHandle = {
|
|
|
13
13
|
unpause: jest.fn(),
|
|
14
14
|
delete: jest.fn(),
|
|
15
15
|
};
|
|
16
|
+
const mockWorkflowHandle = {
|
|
17
|
+
cancel: jest.fn(),
|
|
18
|
+
result: jest.fn(),
|
|
19
|
+
};
|
|
16
20
|
const mockClient = {
|
|
17
21
|
schedule: {
|
|
18
22
|
getHandle: jest.fn(() => mockHandle),
|
|
@@ -20,7 +24,7 @@ const mockClient = {
|
|
|
20
24
|
},
|
|
21
25
|
workflow: {
|
|
22
26
|
start: jest.fn(),
|
|
23
|
-
getHandle: jest.fn(() =>
|
|
27
|
+
getHandle: jest.fn(() => mockWorkflowHandle),
|
|
24
28
|
},
|
|
25
29
|
};
|
|
26
30
|
|
|
@@ -50,6 +54,35 @@ describe('TemporalService — cron schedule methods', () => {
|
|
|
50
54
|
await service.onModuleInit();
|
|
51
55
|
});
|
|
52
56
|
|
|
57
|
+
describe('waitForWorkflowResult', () => {
|
|
58
|
+
it('returns the completed Workflow result', async () => {
|
|
59
|
+
const workflowResult = {
|
|
60
|
+
status: 'COMPLETED',
|
|
61
|
+
output: { ticketId: 'T-42' },
|
|
62
|
+
};
|
|
63
|
+
mockWorkflowHandle.result.mockResolvedValueOnce(workflowResult);
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
service.waitForWorkflowResult('wf-42', 1000),
|
|
67
|
+
).resolves.toEqual({
|
|
68
|
+
kind: 'COMPLETED',
|
|
69
|
+
result: workflowResult,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('stops waiting at the configured timeout without cancelling the Workflow', async () => {
|
|
74
|
+
jest.useFakeTimers();
|
|
75
|
+
mockWorkflowHandle.result.mockReturnValueOnce(new Promise(() => {}));
|
|
76
|
+
|
|
77
|
+
const result = service.waitForWorkflowResult('wf-42', 1000);
|
|
78
|
+
jest.advanceTimersByTime(1000);
|
|
79
|
+
|
|
80
|
+
await expect(result).resolves.toEqual({ kind: 'TIMED_OUT' });
|
|
81
|
+
expect(mockWorkflowHandle.cancel).not.toHaveBeenCalled();
|
|
82
|
+
jest.useRealTimers();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
53
86
|
describe('upsertCronSchedule', () => {
|
|
54
87
|
it('creates a new schedule when none exists', async () => {
|
|
55
88
|
mockHandle.describe.mockRejectedValueOnce(
|
|
@@ -12,6 +12,10 @@ const TASK_QUEUE = process.env.TEMPORAL_TASK_QUEUE || 'workflow-execution';
|
|
|
12
12
|
|
|
13
13
|
const cronScheduleId = (workflowId: number) => `wf-${workflowId}-cron`;
|
|
14
14
|
|
|
15
|
+
export type WorkflowWaitResult =
|
|
16
|
+
| { kind: 'COMPLETED'; result: unknown }
|
|
17
|
+
| { kind: 'TIMED_OUT' };
|
|
18
|
+
|
|
15
19
|
@Injectable()
|
|
16
20
|
export class TemporalService implements OnModuleInit, OnModuleDestroy {
|
|
17
21
|
private connection: Connection;
|
|
@@ -68,6 +72,28 @@ export class TemporalService implements OnModuleInit, OnModuleDestroy {
|
|
|
68
72
|
return { temporalRunId: handle.workflowId };
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
async waitForWorkflowResult(
|
|
76
|
+
temporalRunId: string,
|
|
77
|
+
timeoutMs: number,
|
|
78
|
+
): Promise<WorkflowWaitResult> {
|
|
79
|
+
if (!this.client) throw new Error('Temporal client not connected');
|
|
80
|
+
const handle = this.client.workflow.getHandle(temporalRunId);
|
|
81
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
return await Promise.race([
|
|
85
|
+
handle
|
|
86
|
+
.result()
|
|
87
|
+
.then((result) => ({ kind: 'COMPLETED', result } as const)),
|
|
88
|
+
new Promise<{ kind: 'TIMED_OUT' }>((resolve) => {
|
|
89
|
+
timeout = setTimeout(() => resolve({ kind: 'TIMED_OUT' }), timeoutMs);
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
if (timeout) clearTimeout(timeout);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
/**
|
|
72
98
|
* Cancel a running workflow.
|
|
73
99
|
*/
|
|
@@ -2,6 +2,155 @@ import { hashNodeInstanceConfig } from '../workflowNodeInstance/node-instance-co
|
|
|
2
2
|
import { WorkflowCompilerService } from './workflow-compiler.service';
|
|
3
3
|
|
|
4
4
|
describe('WorkflowCompilerService', () => {
|
|
5
|
+
it('compiles a webhook function that returns JSON to its HTTP caller', async () => {
|
|
6
|
+
const prisma = {
|
|
7
|
+
workflowNodeType: {
|
|
8
|
+
findMany: jest.fn().mockResolvedValue([
|
|
9
|
+
{
|
|
10
|
+
id: BigInt(1),
|
|
11
|
+
type: 'webhook_trigger',
|
|
12
|
+
runtimeKey: 'webhook_trigger',
|
|
13
|
+
instancePolicy: 'INLINE_ONLY',
|
|
14
|
+
schemaVersion: 4,
|
|
15
|
+
configSchema: { type: 'object' },
|
|
16
|
+
uiSchema: {},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: BigInt(2),
|
|
20
|
+
type: 'return_result',
|
|
21
|
+
runtimeKey: 'return_result',
|
|
22
|
+
instancePolicy: 'INLINE_ONLY',
|
|
23
|
+
schemaVersion: 4,
|
|
24
|
+
configSchema: { type: 'object' },
|
|
25
|
+
uiSchema: {},
|
|
26
|
+
},
|
|
27
|
+
]),
|
|
28
|
+
},
|
|
29
|
+
workflowNodeInstance: {
|
|
30
|
+
findMany: jest.fn().mockResolvedValue([]),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const service = new WorkflowCompilerService(
|
|
34
|
+
prisma as any,
|
|
35
|
+
{ validateOrThrow: jest.fn() } as any,
|
|
36
|
+
{ assertUiSchemaSupported: jest.fn() } as any,
|
|
37
|
+
{ validateSubjects: jest.fn() } as any,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const compiled = await service.compile(
|
|
41
|
+
{
|
|
42
|
+
nodes: [
|
|
43
|
+
{
|
|
44
|
+
id: 'webhook',
|
|
45
|
+
type: 'webhook_trigger',
|
|
46
|
+
config: {
|
|
47
|
+
path: 'create-ticket',
|
|
48
|
+
method: 'POST',
|
|
49
|
+
verificationMode: 'NONE',
|
|
50
|
+
responseMode: 'WAIT_FOR_RESULT',
|
|
51
|
+
responseTimeoutSeconds: 60,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'return',
|
|
56
|
+
type: 'return_result',
|
|
57
|
+
config: {
|
|
58
|
+
resultType: 'JSON',
|
|
59
|
+
sourceMode: 'WORKFLOW',
|
|
60
|
+
path: 'body',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
edges: [
|
|
65
|
+
{
|
|
66
|
+
id: 'webhook-return',
|
|
67
|
+
source: 'webhook',
|
|
68
|
+
target: 'return',
|
|
69
|
+
kind: 'FLOW',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{ userId: 'author' },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(compiled.nodes).toEqual([
|
|
77
|
+
expect.objectContaining({ type: 'webhook_trigger' }),
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
type: 'return_result',
|
|
80
|
+
config: expect.objectContaining({ resultType: 'JSON' }),
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects a webhook function whose flow can finish without returning', async () => {
|
|
86
|
+
const prisma = {
|
|
87
|
+
workflowNodeType: {
|
|
88
|
+
findMany: jest.fn().mockResolvedValue([
|
|
89
|
+
{
|
|
90
|
+
id: BigInt(1),
|
|
91
|
+
type: 'webhook_trigger',
|
|
92
|
+
runtimeKey: 'webhook_trigger',
|
|
93
|
+
instancePolicy: 'INLINE_ONLY',
|
|
94
|
+
schemaVersion: 4,
|
|
95
|
+
configSchema: { type: 'object' },
|
|
96
|
+
uiSchema: {},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: BigInt(2),
|
|
100
|
+
type: 'code',
|
|
101
|
+
runtimeKey: 'code',
|
|
102
|
+
instancePolicy: 'OPTIONAL',
|
|
103
|
+
schemaVersion: 4,
|
|
104
|
+
configSchema: { type: 'object' },
|
|
105
|
+
uiSchema: {},
|
|
106
|
+
},
|
|
107
|
+
]),
|
|
108
|
+
},
|
|
109
|
+
workflowNodeInstance: {
|
|
110
|
+
findMany: jest.fn().mockResolvedValue([]),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const service = new WorkflowCompilerService(
|
|
114
|
+
prisma as any,
|
|
115
|
+
{ validateOrThrow: jest.fn() } as any,
|
|
116
|
+
{ assertUiSchemaSupported: jest.fn() } as any,
|
|
117
|
+
{ validateSubjects: jest.fn() } as any,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await expect(
|
|
121
|
+
service.compile(
|
|
122
|
+
{
|
|
123
|
+
nodes: [
|
|
124
|
+
{
|
|
125
|
+
id: 'webhook',
|
|
126
|
+
type: 'webhook_trigger',
|
|
127
|
+
config: {
|
|
128
|
+
path: 'create-ticket',
|
|
129
|
+
method: 'POST',
|
|
130
|
+
responseMode: 'WAIT_FOR_RESULT',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{ id: 'code', type: 'code', config: {} },
|
|
134
|
+
],
|
|
135
|
+
edges: [
|
|
136
|
+
{
|
|
137
|
+
id: 'webhook-code',
|
|
138
|
+
source: 'webhook',
|
|
139
|
+
target: 'code',
|
|
140
|
+
kind: 'FLOW',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{ userId: 'author' },
|
|
145
|
+
),
|
|
146
|
+
).rejects.toMatchObject({
|
|
147
|
+
response: {
|
|
148
|
+
message: '等待返回结果时,每个流程分支都必须以“返回结果”结束',
|
|
149
|
+
nodeId: 'webhook',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
5
154
|
it('snapshots the current Node Instance config into the compiled workflow', async () => {
|
|
6
155
|
const instance = {
|
|
7
156
|
id: BigInt(12),
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
DraftWorkflowNode,
|
|
21
21
|
WorkflowCompilerErrorCode,
|
|
22
22
|
} from './workflow-compiler.types';
|
|
23
|
+
import { validateWebhookResponseWorkflow } from './workflow-http-response';
|
|
23
24
|
|
|
24
25
|
@Injectable()
|
|
25
26
|
export class WorkflowCompilerService {
|
|
@@ -307,12 +308,7 @@ export class WorkflowCompilerService {
|
|
|
307
308
|
}
|
|
308
309
|
|
|
309
310
|
const compiledDsl = { ...clone(draftDsl), nodes: compiledNodes };
|
|
310
|
-
if (
|
|
311
|
-
compiledNodes.some(
|
|
312
|
-
(node) =>
|
|
313
|
-
node.type === 'callable_trigger' || node.type === 'return_result',
|
|
314
|
-
)
|
|
315
|
-
) {
|
|
311
|
+
if (compiledNodes.some((node) => node.type === 'callable_trigger')) {
|
|
316
312
|
try {
|
|
317
313
|
validateCallableWorkflow(compiledDsl);
|
|
318
314
|
} catch (error) {
|
|
@@ -327,6 +323,24 @@ export class WorkflowCompilerService {
|
|
|
327
323
|
);
|
|
328
324
|
}
|
|
329
325
|
}
|
|
326
|
+
try {
|
|
327
|
+
validateWebhookResponseWorkflow(compiledDsl);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const boundaryNode =
|
|
330
|
+
draftDsl.nodes.find(
|
|
331
|
+
(node) =>
|
|
332
|
+
node.type === 'webhook_trigger' &&
|
|
333
|
+
(node.config as Record<string, unknown> | undefined)
|
|
334
|
+
?.responseMode === 'WAIT_FOR_RESULT',
|
|
335
|
+
) ??
|
|
336
|
+
draftDsl.nodes.find((node) => node.type === 'return_result') ??
|
|
337
|
+
draftDsl.nodes[0];
|
|
338
|
+
this.fail(
|
|
339
|
+
'NODE_TYPE_MISMATCH',
|
|
340
|
+
boundaryNode,
|
|
341
|
+
error instanceof Error ? error.message : 'HTTP 返回配置无效',
|
|
342
|
+
);
|
|
343
|
+
}
|
|
330
344
|
return compiledDsl;
|
|
331
345
|
}
|
|
332
346
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { validateWebhookResponseWorkflow } from './workflow-http-response';
|
|
2
|
+
|
|
3
|
+
const trigger = {
|
|
4
|
+
id: 'webhook',
|
|
5
|
+
type: 'webhook_trigger',
|
|
6
|
+
config: {
|
|
7
|
+
responseMode: 'WAIT_FOR_RESULT',
|
|
8
|
+
responseTimeoutSeconds: 60,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const returnNode = (id: string) => ({
|
|
13
|
+
id,
|
|
14
|
+
type: 'return_result',
|
|
15
|
+
config: {
|
|
16
|
+
resultType: 'JSON',
|
|
17
|
+
sourceMode: 'INPUT',
|
|
18
|
+
httpStatusCode: 200,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('webhook function response contract', () => {
|
|
23
|
+
it('accepts conditional branches that each return their own result', () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
validateWebhookResponseWorkflow({
|
|
26
|
+
nodes: [
|
|
27
|
+
trigger,
|
|
28
|
+
{ id: 'branch', type: 'if_else', config: {} },
|
|
29
|
+
returnNode('accepted'),
|
|
30
|
+
returnNode('rejected'),
|
|
31
|
+
],
|
|
32
|
+
edges: [
|
|
33
|
+
{ source: 'webhook', target: 'branch', kind: 'FLOW' },
|
|
34
|
+
{ source: 'branch', target: 'accepted', kind: 'FLOW' },
|
|
35
|
+
{ source: 'branch', target: 'rejected', kind: 'FLOW' },
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects a function-mode webhook with a terminal branch that does not return', () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
validateWebhookResponseWorkflow({
|
|
44
|
+
nodes: [
|
|
45
|
+
trigger,
|
|
46
|
+
{ id: 'branch', type: 'if_else', config: {} },
|
|
47
|
+
returnNode('accepted'),
|
|
48
|
+
{ id: 'unfinished', type: 'code', config: {} },
|
|
49
|
+
],
|
|
50
|
+
edges: [
|
|
51
|
+
{ source: 'webhook', target: 'branch', kind: 'FLOW' },
|
|
52
|
+
{ source: 'branch', target: 'accepted', kind: 'FLOW' },
|
|
53
|
+
{ source: 'branch', target: 'unfinished', kind: 'FLOW' },
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
).toThrow('等待返回结果时,每个流程分支都必须以“返回结果”结束');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects an approval-only return value for an HTTP response', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
validateWebhookResponseWorkflow({
|
|
62
|
+
nodes: [
|
|
63
|
+
trigger,
|
|
64
|
+
{
|
|
65
|
+
...returnNode('return'),
|
|
66
|
+
config: {
|
|
67
|
+
resultType: 'APPROVAL_SUBJECTS',
|
|
68
|
+
sourceMode: 'INPUT',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
edges: [{ source: 'webhook', target: 'return', kind: 'FLOW' }],
|
|
73
|
+
}),
|
|
74
|
+
).toThrow('HTTP 调用只能返回普通数据');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects returning a previous result when only the trigger is connected', () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
validateWebhookResponseWorkflow({
|
|
80
|
+
nodes: [trigger, returnNode('return')],
|
|
81
|
+
edges: [{ source: 'webhook', target: 'return', kind: 'FLOW' }],
|
|
82
|
+
}),
|
|
83
|
+
).toThrow('返回上一步结果时,“返回结果”前需要连接一个处理节点');
|
|
84
|
+
});
|
|
85
|
+
});
|