@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.
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
@@ -51,7 +51,7 @@ export const Event: ModelConfig = {
51
51
  action: "EDIT",
52
52
  desc: "Edit",
53
53
  },
54
-
54
+ { action: "DETAIL", desc: "详情" },
55
55
  { action: "DELETE", desc: "Delete" },
56
56
  ],
57
57
  },
@@ -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.71",
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) : 7 * 24 * 60 * 60; // 默认 7 天
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
  {
@@ -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: 'APPROVAL_SUBJECTS',
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: 3,
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: 3,
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 父分组 + 2 个 leaf (workflow_list / workflow_node_instance)
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(prisma, {
124
- code: def.code,
125
- name: def.code,
126
- zhName: def.zhName,
127
- enName: def.enName,
128
- sortOrder: def.sortOrder,
129
- parentId: workflowPage.id,
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(() => ({ cancel: 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
+ });