@gadmin2n/schematics 0.0.95 → 0.0.97
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/DESIGN.md +348 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/PRODUCT.md +75 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +5 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +24 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +32 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.service.ts +28 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.spec.ts +220 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.ts +129 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.dto.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.service.ts +4 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +46 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +27 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeType/workflowNodeType.service.spec.ts +6 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/node-types.ts +43 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/validate.ts +109 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/validate.test.ts +205 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/promptGenerator.ts +19 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/header.tsx +55 -56
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/layout.tsx +7 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/logo.tsx +7 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/sider.tsx +179 -160
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/title.tsx +34 -31
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/constants/layout.ts +24 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +24 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +24 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +173 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +27 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasApi.ts +29 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasConfigRegistry.tsx +2 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasContextMenuRegistry.tsx +98 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/TableConfigModal.tsx +192 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/utils/tableCodeUtils.ts +338 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CustomNode.tsx +66 -51
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +7 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.tsx +66 -26
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/FlowRenderer.tsx +7 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +9 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useWorkflowAgent.ts +30 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/show.tsx +9 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveOutputs.ts +27 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Body,
|
|
3
3
|
Controller,
|
|
4
4
|
Delete,
|
|
5
|
+
ForbiddenException,
|
|
5
6
|
Get,
|
|
6
7
|
Param,
|
|
7
8
|
ParseIntPipe,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
UseGuards,
|
|
12
13
|
} from '@nestjs/common';
|
|
13
14
|
import { ApiTags } from '@nestjs/swagger';
|
|
15
|
+
import { PrismaService } from 'nestjs-prisma';
|
|
14
16
|
import AuthGuard, { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
15
17
|
import {
|
|
16
18
|
CreateCanvasDto,
|
|
@@ -24,13 +26,29 @@ import { CanvasService } from './canvas.service';
|
|
|
24
26
|
@UseGuards(AuthGuard)
|
|
25
27
|
@Controller('canvas')
|
|
26
28
|
export class CanvasController {
|
|
27
|
-
constructor(
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly canvasService: CanvasService,
|
|
31
|
+
private readonly prisma: PrismaService,
|
|
32
|
+
) {}
|
|
28
33
|
|
|
29
34
|
@Get()
|
|
30
35
|
findAll(@Req() req) {
|
|
31
36
|
return this.canvasService.findAllByUser(req.user.userid);
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
@Get('all')
|
|
40
|
+
async findAllForAdmin(@Req() req) {
|
|
41
|
+
const record = await this.prisma.user.findFirst({
|
|
42
|
+
where: { userid: req.user.userid },
|
|
43
|
+
select: { roles: true },
|
|
44
|
+
});
|
|
45
|
+
const roles: string[] = record ? JSON.parse(record.roles) : [];
|
|
46
|
+
if (!roles.includes('SYSTEM_ADMIN')) {
|
|
47
|
+
throw new ForbiddenException('仅 SYSTEM_ADMIN 可访问');
|
|
48
|
+
}
|
|
49
|
+
return this.canvasService.findAllCanvases();
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
@Post()
|
|
35
53
|
create(@Req() req, @Body() dto: CreateCanvasDto) {
|
|
36
54
|
return this.canvasService.create(req.user.userid, dto);
|
|
@@ -56,12 +74,23 @@ export class CanvasController {
|
|
|
56
74
|
|
|
57
75
|
@Get(':id')
|
|
58
76
|
findOne(@Req() req, @Param('id') id: string) {
|
|
59
|
-
|
|
77
|
+
const roles: string[] = req.user?.configPerms?.roles ?? [];
|
|
78
|
+
const isAdmin = roles.includes('SYSTEM_ADMIN');
|
|
79
|
+
return this.canvasService.findOne(
|
|
80
|
+
id,
|
|
81
|
+
isAdmin ? undefined : req.user.userid,
|
|
82
|
+
);
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
@Put(':id')
|
|
63
86
|
update(@Req() req, @Param('id') id: string, @Body() dto: UpdateCanvasDto) {
|
|
64
|
-
|
|
87
|
+
const roles: string[] = req.user?.configPerms?.roles ?? [];
|
|
88
|
+
const isAdmin = roles.includes('SYSTEM_ADMIN');
|
|
89
|
+
return this.canvasService.update(
|
|
90
|
+
id,
|
|
91
|
+
isAdmin ? undefined : req.user.userid,
|
|
92
|
+
dto,
|
|
93
|
+
);
|
|
65
94
|
}
|
|
66
95
|
|
|
67
96
|
@Delete(':id')
|
|
@@ -25,9 +25,9 @@ export class CanvasService {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
findOne(id: string, userId
|
|
28
|
+
findOne(id: string, userId?: string) {
|
|
29
29
|
return this.prisma.canvas.findFirst({
|
|
30
|
-
where: { id, userId },
|
|
30
|
+
where: userId ? { id, userId } : { id },
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -58,10 +58,12 @@ export class CanvasService {
|
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async update(id: string, userId: string, dto: UpdateCanvasDto) {
|
|
61
|
+
async update(id: string, userId: string | undefined, dto: UpdateCanvasDto) {
|
|
62
62
|
if (dto.name !== undefined) {
|
|
63
63
|
const existing = await this.prisma.canvas.findFirst({
|
|
64
|
-
where:
|
|
64
|
+
where: userId
|
|
65
|
+
? { userId, name: dto.name, NOT: { id } }
|
|
66
|
+
: { name: dto.name, NOT: { id } },
|
|
65
67
|
});
|
|
66
68
|
if (existing) {
|
|
67
69
|
throw new ConflictException('Canvas 名称已存在');
|
|
@@ -71,7 +73,7 @@ export class CanvasService {
|
|
|
71
73
|
// 如果改名且已发布,同步更新 page 表的所有名称字段 + code + path
|
|
72
74
|
if (dto.name !== undefined) {
|
|
73
75
|
const canvas = await this.prisma.canvas.findFirst({
|
|
74
|
-
where: { id, userId },
|
|
76
|
+
where: userId ? { id, userId } : { id },
|
|
75
77
|
});
|
|
76
78
|
if (canvas?.publishedPageCode) {
|
|
77
79
|
const newSlug =
|
|
@@ -100,7 +102,7 @@ export class CanvasService {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
return this.prisma.canvas.updateMany({
|
|
103
|
-
where: { id, userId },
|
|
105
|
+
where: userId ? { id, userId } : { id },
|
|
104
106
|
data: {
|
|
105
107
|
...(dto.name !== undefined && { name: dto.name }),
|
|
106
108
|
...(dto.items !== undefined && { items: dto.items }),
|
|
@@ -228,6 +230,26 @@ export class CanvasService {
|
|
|
228
230
|
return { success: true };
|
|
229
231
|
}
|
|
230
232
|
|
|
233
|
+
async findAllCanvases() {
|
|
234
|
+
const canvases = await this.prisma.canvas.findMany({
|
|
235
|
+
orderBy: { createdAt: 'asc' },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const userIds = [...new Set(canvases.map((c) => c.userId))];
|
|
239
|
+
const users = await this.prisma.user.findMany({
|
|
240
|
+
where: { userid: { in: userIds } },
|
|
241
|
+
select: { userid: true, username: true },
|
|
242
|
+
});
|
|
243
|
+
const userMap = new Map(
|
|
244
|
+
users.map((u) => [u.userid, u.username ?? u.userid]),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return canvases.map((c) => ({
|
|
248
|
+
...c,
|
|
249
|
+
ownerName: userMap.get(c.userId) ?? c.userId,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
231
253
|
async savedQueryCreate(dto: CreateSavedQueryDto) {
|
|
232
254
|
const sql = dto.sql.trim();
|
|
233
255
|
if (!sql.toUpperCase().startsWith('SELECT')) {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateDslHandles,
|
|
3
|
+
type NodeOutputsMap,
|
|
4
|
+
type WorkflowDsl,
|
|
5
|
+
} from './workflow-dsl-validate';
|
|
6
|
+
|
|
7
|
+
// Test fixture: a small NodeOutputsMap mirroring the live DB seed for the
|
|
8
|
+
// node types we exercise here. Mirrors NODE_TYPE_META used in the worker test.
|
|
9
|
+
const OUTPUTS_MAP: NodeOutputsMap = {
|
|
10
|
+
if_else: ['true', 'false'],
|
|
11
|
+
switch: null,
|
|
12
|
+
cron_trigger: [],
|
|
13
|
+
manual_trigger: [],
|
|
14
|
+
webhook_trigger: [],
|
|
15
|
+
http_request: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeNode(
|
|
21
|
+
id: string,
|
|
22
|
+
type: string,
|
|
23
|
+
config: Record<string, any> = {},
|
|
24
|
+
): WorkflowDsl['nodes'][number] {
|
|
25
|
+
return { id, type, config };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeEdge(
|
|
29
|
+
id: string,
|
|
30
|
+
source: string,
|
|
31
|
+
target: string,
|
|
32
|
+
sourceHandle?: string,
|
|
33
|
+
): WorkflowDsl['edges'][number] {
|
|
34
|
+
return sourceHandle === undefined
|
|
35
|
+
? { id, source, target }
|
|
36
|
+
: { id, source, target, sourceHandle };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── if_else (enumerated outputs) ────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe('validateDslHandles — if_else', () => {
|
|
42
|
+
it('accepts sourceHandle "true"', () => {
|
|
43
|
+
const dsl: WorkflowDsl = {
|
|
44
|
+
nodes: [makeNode('a', 'if_else'), makeNode('b', 'http_request')],
|
|
45
|
+
edges: [makeEdge('e1', 'a', 'b', 'true')],
|
|
46
|
+
};
|
|
47
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('accepts sourceHandle "false"', () => {
|
|
51
|
+
const dsl: WorkflowDsl = {
|
|
52
|
+
nodes: [makeNode('a', 'if_else'), makeNode('b', 'http_request')],
|
|
53
|
+
edges: [makeEdge('e1', 'a', 'b', 'false')],
|
|
54
|
+
};
|
|
55
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects unknown sourceHandle "maybe"', () => {
|
|
59
|
+
const dsl: WorkflowDsl = {
|
|
60
|
+
nodes: [makeNode('a', 'if_else'), makeNode('b', 'http_request')],
|
|
61
|
+
edges: [makeEdge('e1', 'a', 'b', 'maybe')],
|
|
62
|
+
};
|
|
63
|
+
const errors = validateDslHandles(dsl, OUTPUTS_MAP);
|
|
64
|
+
expect(errors).toHaveLength(1);
|
|
65
|
+
expect(errors[0].edgeId).toBe('e1');
|
|
66
|
+
expect(errors[0].code).toBe('UNKNOWN_HANDLE');
|
|
67
|
+
expect(errors[0].reason).toMatch(/not in declared outputs/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects missing sourceHandle', () => {
|
|
71
|
+
const dsl: WorkflowDsl = {
|
|
72
|
+
nodes: [makeNode('a', 'if_else'), makeNode('b', 'http_request')],
|
|
73
|
+
edges: [makeEdge('e1', 'a', 'b')],
|
|
74
|
+
};
|
|
75
|
+
const errors = validateDslHandles(dsl, OUTPUTS_MAP);
|
|
76
|
+
expect(errors).toHaveLength(1);
|
|
77
|
+
expect(errors[0].edgeId).toBe('e1');
|
|
78
|
+
expect(errors[0].code).toBe('MISSING_HANDLE');
|
|
79
|
+
expect(errors[0].reason).toMatch(/requires a sourceHandle/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects empty-string sourceHandle as missing', () => {
|
|
83
|
+
const dsl: WorkflowDsl = {
|
|
84
|
+
nodes: [makeNode('a', 'if_else'), makeNode('b', 'http_request')],
|
|
85
|
+
edges: [makeEdge('e1', 'a', 'b', '')],
|
|
86
|
+
};
|
|
87
|
+
const errors = validateDslHandles(dsl, OUTPUTS_MAP);
|
|
88
|
+
expect(errors).toHaveLength(1);
|
|
89
|
+
expect(errors[0].edgeId).toBe('e1');
|
|
90
|
+
expect(errors[0].code).toBe('MISSING_HANDLE');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ─── cron_trigger (no named outputs) ─────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe('validateDslHandles — cron_trigger (no named outputs)', () => {
|
|
97
|
+
it('rejects sourceHandle "foo" on a node with no named handles', () => {
|
|
98
|
+
const dsl: WorkflowDsl = {
|
|
99
|
+
nodes: [makeNode('t', 'cron_trigger'), makeNode('a', 'http_request')],
|
|
100
|
+
edges: [makeEdge('e1', 't', 'a', 'foo')],
|
|
101
|
+
};
|
|
102
|
+
const errors = validateDslHandles(dsl, OUTPUTS_MAP);
|
|
103
|
+
expect(errors).toHaveLength(1);
|
|
104
|
+
expect(errors[0].edgeId).toBe('e1');
|
|
105
|
+
expect(errors[0].code).toBe('EXTRA_HANDLE');
|
|
106
|
+
expect(errors[0].reason).toMatch(/no named output handles/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('accepts no sourceHandle', () => {
|
|
110
|
+
const dsl: WorkflowDsl = {
|
|
111
|
+
nodes: [makeNode('t', 'cron_trigger'), makeNode('a', 'http_request')],
|
|
112
|
+
edges: [makeEdge('e1', 't', 'a')],
|
|
113
|
+
};
|
|
114
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts empty-string sourceHandle (treated as absent for [] nodes)', () => {
|
|
118
|
+
const dsl: WorkflowDsl = {
|
|
119
|
+
nodes: [makeNode('t', 'cron_trigger'), makeNode('a', 'http_request')],
|
|
120
|
+
edges: [makeEdge('e1', 't', 'a', '')],
|
|
121
|
+
};
|
|
122
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ─── switch (dynamic outputs) ────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe('validateDslHandles — switch (dynamic outputs)', () => {
|
|
129
|
+
it('accepts sourceHandle matching a case value', () => {
|
|
130
|
+
const dsl: WorkflowDsl = {
|
|
131
|
+
nodes: [
|
|
132
|
+
makeNode('s', 'switch', { cases: [{ value: 'a' }, { value: 'b' }] }),
|
|
133
|
+
makeNode('x', 'http_request'),
|
|
134
|
+
],
|
|
135
|
+
edges: [makeEdge('e1', 's', 'x', 'a')],
|
|
136
|
+
};
|
|
137
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rejects sourceHandle not in case values', () => {
|
|
141
|
+
const dsl: WorkflowDsl = {
|
|
142
|
+
nodes: [
|
|
143
|
+
makeNode('s', 'switch', { cases: [{ value: 'a' }] }),
|
|
144
|
+
makeNode('x', 'http_request'),
|
|
145
|
+
],
|
|
146
|
+
edges: [makeEdge('e1', 's', 'x', 'c')],
|
|
147
|
+
};
|
|
148
|
+
const errors = validateDslHandles(dsl, OUTPUTS_MAP);
|
|
149
|
+
expect(errors).toHaveLength(1);
|
|
150
|
+
expect(errors[0].edgeId).toBe('e1');
|
|
151
|
+
expect(errors[0].code).toBe('UNKNOWN_HANDLE');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('skips validation when switch config has no cases (in-progress editor)', () => {
|
|
155
|
+
const dsl: WorkflowDsl = {
|
|
156
|
+
nodes: [makeNode('s', 'switch'), makeNode('x', 'http_request')],
|
|
157
|
+
edges: [makeEdge('e1', 's', 'x', 'anything')],
|
|
158
|
+
};
|
|
159
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── out-of-scope (orphan / unknown) ────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe('validateDslHandles — out-of-scope', () => {
|
|
166
|
+
it('ignores edge referencing missing source node', () => {
|
|
167
|
+
const dsl: WorkflowDsl = {
|
|
168
|
+
nodes: [makeNode('b', 'http_request')],
|
|
169
|
+
edges: [makeEdge('e1', 'ghost', 'b', 'true')],
|
|
170
|
+
};
|
|
171
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('ignores edges from unknown node types', () => {
|
|
175
|
+
const dsl: WorkflowDsl = {
|
|
176
|
+
nodes: [
|
|
177
|
+
makeNode('u', 'wholly_made_up_type'),
|
|
178
|
+
makeNode('b', 'http_request'),
|
|
179
|
+
],
|
|
180
|
+
edges: [makeEdge('e1', 'u', 'b', 'whatever')],
|
|
181
|
+
};
|
|
182
|
+
expect(validateDslHandles(dsl, OUTPUTS_MAP)).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─── multiple errors collected ──────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe('validateDslHandles — multiple errors', () => {
|
|
189
|
+
it('collects every error in one pass', () => {
|
|
190
|
+
const dsl: WorkflowDsl = {
|
|
191
|
+
nodes: [
|
|
192
|
+
makeNode('if', 'if_else'),
|
|
193
|
+
makeNode('cron', 'cron_trigger'),
|
|
194
|
+
makeNode('sw', 'switch', { cases: [{ value: 'a' }] }),
|
|
195
|
+
makeNode('x', 'http_request'),
|
|
196
|
+
],
|
|
197
|
+
edges: [
|
|
198
|
+
makeEdge('e1', 'if', 'x', 'maybe'), // bad enumerated
|
|
199
|
+
makeEdge('e2', 'if', 'x'), // missing required
|
|
200
|
+
makeEdge('e3', 'cron', 'x', 'foo'), // forbidden handle
|
|
201
|
+
makeEdge('e4', 'sw', 'x', 'c'), // bad dynamic
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
const errors = validateDslHandles(dsl, OUTPUTS_MAP);
|
|
205
|
+
expect(errors).toHaveLength(4);
|
|
206
|
+
expect(errors.map((e) => e.edgeId).sort()).toEqual([
|
|
207
|
+
'e1',
|
|
208
|
+
'e2',
|
|
209
|
+
'e3',
|
|
210
|
+
'e4',
|
|
211
|
+
]);
|
|
212
|
+
const byEdge = Object.fromEntries(errors.map((e) => [e.edgeId, e.code]));
|
|
213
|
+
expect(byEdge).toEqual({
|
|
214
|
+
e1: 'UNKNOWN_HANDLE',
|
|
215
|
+
e2: 'MISSING_HANDLE',
|
|
216
|
+
e3: 'EXTRA_HANDLE',
|
|
217
|
+
e4: 'UNKNOWN_HANDLE',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSL handle validator (server-side).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors temporal/worker/src/dsl/validate.ts but is independent:
|
|
5
|
+
* - reads node-type outputs from a passed-in map (caller fetches from DB)
|
|
6
|
+
* - returns the same DslValidationCode discriminator for FE compatibility
|
|
7
|
+
*
|
|
8
|
+
* Why duplicated rather than imported from worker: server and worker are
|
|
9
|
+
* separate projects without a shared package; cross-project imports would
|
|
10
|
+
* break tsc paths and Docker build.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type DslValidationCode =
|
|
14
|
+
| 'EXTRA_HANDLE' // node has [] outputs, but edge set sourceHandle
|
|
15
|
+
| 'MISSING_HANDLE' // node has named outputs, but edge has no sourceHandle (or empty string)
|
|
16
|
+
| 'UNKNOWN_HANDLE'; // edge sourceHandle is set but ∉ declared outputs
|
|
17
|
+
|
|
18
|
+
export interface DslValidationError {
|
|
19
|
+
edgeId: string;
|
|
20
|
+
source: string;
|
|
21
|
+
target: string;
|
|
22
|
+
code: DslValidationCode;
|
|
23
|
+
reason: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DslNode {
|
|
27
|
+
id: string;
|
|
28
|
+
type: string;
|
|
29
|
+
config?: Record<string, any>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DslEdge {
|
|
33
|
+
id: string;
|
|
34
|
+
source: string;
|
|
35
|
+
target: string;
|
|
36
|
+
sourceHandle?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkflowDsl {
|
|
40
|
+
nodes: DslNode[];
|
|
41
|
+
edges: DslEdge[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Map from node type name -> outputs declaration.
|
|
46
|
+
*
|
|
47
|
+
* - null: dynamic (e.g. switch — derived from node.config)
|
|
48
|
+
* - []: single anonymous output, no sourceHandle allowed
|
|
49
|
+
* - ['a','b',…]: enumerated named outputs; sourceHandle MUST be one of them
|
|
50
|
+
* - undefined: unknown type; validator skips edges from such nodes
|
|
51
|
+
*/
|
|
52
|
+
export type NodeOutputsMap = Record<string, string[] | null>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate every edge's sourceHandle against the source node's outputs metadata.
|
|
56
|
+
* Returns an array of errors (empty if DSL is valid).
|
|
57
|
+
*/
|
|
58
|
+
export function validateDslHandles(
|
|
59
|
+
dsl: WorkflowDsl,
|
|
60
|
+
outputsMap: NodeOutputsMap,
|
|
61
|
+
): DslValidationError[] {
|
|
62
|
+
const errors: DslValidationError[] = [];
|
|
63
|
+
const nodeMap = new Map<string, DslNode>(dsl.nodes.map((n) => [n.id, n]));
|
|
64
|
+
|
|
65
|
+
for (const edge of dsl.edges) {
|
|
66
|
+
const src = nodeMap.get(edge.source);
|
|
67
|
+
if (!src) continue; // separate concern: orphan edge — not this validator's job
|
|
68
|
+
if (!(src.type in outputsMap)) continue; // unknown node type — out of scope
|
|
69
|
+
|
|
70
|
+
const declared = outputsMap[src.type];
|
|
71
|
+
const handles =
|
|
72
|
+
declared === null ? deriveDynamicOutputs(src) : declared;
|
|
73
|
+
|
|
74
|
+
// Empty named-handle list AND declared-non-null => no sourceHandle allowed
|
|
75
|
+
if (declared !== null && handles.length === 0) {
|
|
76
|
+
if (edge.sourceHandle != null && edge.sourceHandle !== '') {
|
|
77
|
+
errors.push({
|
|
78
|
+
edgeId: edge.id,
|
|
79
|
+
source: edge.source,
|
|
80
|
+
target: edge.target,
|
|
81
|
+
code: 'EXTRA_HANDLE',
|
|
82
|
+
reason: `Node "${src.type}" has no named output handles; edge must not set sourceHandle (got "${edge.sourceHandle}").`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Dynamic + still-empty (e.g. switch with no cases): in-progress, skip
|
|
89
|
+
if (handles.length === 0) continue;
|
|
90
|
+
|
|
91
|
+
if (edge.sourceHandle == null || edge.sourceHandle === '') {
|
|
92
|
+
errors.push({
|
|
93
|
+
edgeId: edge.id,
|
|
94
|
+
source: edge.source,
|
|
95
|
+
target: edge.target,
|
|
96
|
+
code: 'MISSING_HANDLE',
|
|
97
|
+
reason: `Node "${src.type}" requires a sourceHandle; choose one of: ${handles.join(', ')}.`,
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!handles.includes(edge.sourceHandle)) {
|
|
103
|
+
errors.push({
|
|
104
|
+
edgeId: edge.id,
|
|
105
|
+
source: edge.source,
|
|
106
|
+
target: edge.target,
|
|
107
|
+
code: 'UNKNOWN_HANDLE',
|
|
108
|
+
reason: `Node "${src.type}" sourceHandle "${edge.sourceHandle}" is not in declared outputs: ${handles.join(', ')}.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return errors;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* For dynamic-output nodes (currently just `switch`), derive the list of
|
|
118
|
+
* handle ids from the node's config.
|
|
119
|
+
*/
|
|
120
|
+
function deriveDynamicOutputs(node: DslNode): string[] {
|
|
121
|
+
if (node.type === 'switch') {
|
|
122
|
+
const cases = node.config?.cases as Array<{ value?: string }> | undefined;
|
|
123
|
+
if (!Array.isArray(cases)) return [];
|
|
124
|
+
return cases
|
|
125
|
+
.map((c) => c?.value)
|
|
126
|
+
.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
127
|
+
}
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
@@ -129,6 +129,7 @@ export class WorkflowExportService {
|
|
|
129
129
|
configSchema: nt.configSchema as any,
|
|
130
130
|
inputSchema: nt.inputSchema as any,
|
|
131
131
|
outputSchema: nt.outputSchema as any,
|
|
132
|
+
outputs: nt.outputs as any,
|
|
132
133
|
isBuiltin: nt.isBuiltin,
|
|
133
134
|
})),
|
|
134
135
|
};
|
|
@@ -193,6 +194,7 @@ export class WorkflowExportService {
|
|
|
193
194
|
configSchema: nt.configSchema as any,
|
|
194
195
|
inputSchema: nt.inputSchema as any,
|
|
195
196
|
outputSchema: nt.outputSchema as any,
|
|
197
|
+
outputs: nt.outputs as any,
|
|
196
198
|
isBuiltin: nt.isBuiltin,
|
|
197
199
|
})),
|
|
198
200
|
}
|
|
@@ -224,6 +226,7 @@ export class WorkflowExportService {
|
|
|
224
226
|
configSchema: nt.configSchema,
|
|
225
227
|
inputSchema: nt.inputSchema,
|
|
226
228
|
outputSchema: nt.outputSchema,
|
|
229
|
+
outputs: nt.outputs,
|
|
227
230
|
isBuiltin: nt.isBuiltin,
|
|
228
231
|
creator,
|
|
229
232
|
},
|
|
@@ -235,6 +238,7 @@ export class WorkflowExportService {
|
|
|
235
238
|
configSchema: nt.configSchema,
|
|
236
239
|
inputSchema: nt.inputSchema,
|
|
237
240
|
outputSchema: nt.outputSchema,
|
|
241
|
+
outputs: nt.outputs,
|
|
238
242
|
},
|
|
239
243
|
});
|
|
240
244
|
}
|
|
@@ -22,6 +22,16 @@ describe('WorkflowService — cron lifecycle hooks', () => {
|
|
|
22
22
|
findFirst: jest.fn(),
|
|
23
23
|
create: jest.fn(),
|
|
24
24
|
},
|
|
25
|
+
workflowNodeType: {
|
|
26
|
+
findMany: jest.fn().mockResolvedValue([
|
|
27
|
+
{ type: 'cron_trigger', outputs: [] },
|
|
28
|
+
{ type: 'manual_trigger', outputs: [] },
|
|
29
|
+
{ type: 'webhook_trigger', outputs: [] },
|
|
30
|
+
{ type: 'http_request', outputs: [] },
|
|
31
|
+
{ type: 'if_else', outputs: ['true', 'false'] },
|
|
32
|
+
{ type: 'switch', outputs: null },
|
|
33
|
+
]),
|
|
34
|
+
},
|
|
25
35
|
$transaction: jest.fn(async (cb: any) =>
|
|
26
36
|
cb({
|
|
27
37
|
workflowVersion: {
|
|
@@ -197,6 +207,42 @@ describe('WorkflowService — cron lifecycle hooks', () => {
|
|
|
197
207
|
service.publish(ID, { changeSummary: 'init' }, 'tester'),
|
|
198
208
|
).resolves.toBeDefined();
|
|
199
209
|
});
|
|
210
|
+
|
|
211
|
+
it('rejects with 400 BadRequestException when DSL has invalid sourceHandle', async () => {
|
|
212
|
+
// if_else node has enumerated outputs ['true','false']; sending 'maybe'
|
|
213
|
+
// must trigger the new sourceHandle validator and produce a 400 with a
|
|
214
|
+
// structured error payload the frontend can use to highlight bad edges.
|
|
215
|
+
const dsl = {
|
|
216
|
+
nodes: [
|
|
217
|
+
{ id: 't1', type: 'manual_trigger', config: {} },
|
|
218
|
+
{ id: 'if1', type: 'if_else', config: {} },
|
|
219
|
+
{ id: 'h1', type: 'http_request', config: {} },
|
|
220
|
+
],
|
|
221
|
+
edges: [
|
|
222
|
+
{ id: 'e1', source: 't1', target: 'if1' },
|
|
223
|
+
{ id: 'e2', source: 'if1', target: 'h1', sourceHandle: 'maybe' },
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
prisma.workflow.findUnique.mockResolvedValueOnce(mkWorkflow(dsl));
|
|
227
|
+
|
|
228
|
+
await expect(
|
|
229
|
+
service.publish(ID, { changeSummary: 'init' }, 'tester'),
|
|
230
|
+
).rejects.toMatchObject({
|
|
231
|
+
status: 400,
|
|
232
|
+
response: expect.objectContaining({
|
|
233
|
+
code: 'DSL_HANDLE_VALIDATION',
|
|
234
|
+
errors: expect.arrayContaining([
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
edgeId: 'e2',
|
|
237
|
+
code: 'UNKNOWN_HANDLE',
|
|
238
|
+
}),
|
|
239
|
+
]),
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Status flip must NOT have happened
|
|
244
|
+
expect(prisma.workflow.update).not.toHaveBeenCalled();
|
|
245
|
+
});
|
|
200
246
|
});
|
|
201
247
|
|
|
202
248
|
describe('toggleEnabled', () => {
|
|
@@ -10,6 +10,10 @@ import { PrismaService } from 'nestjs-prisma';
|
|
|
10
10
|
import { validateDslGraph } from './dsl-validate.util';
|
|
11
11
|
import { TemporalService } from './temporal.service';
|
|
12
12
|
import { verifyWebhookSignature } from './webhook-signature.util';
|
|
13
|
+
import {
|
|
14
|
+
validateDslHandles,
|
|
15
|
+
type NodeOutputsMap,
|
|
16
|
+
} from './workflow-dsl-validate';
|
|
13
17
|
|
|
14
18
|
@Injectable()
|
|
15
19
|
export class WorkflowService {
|
|
@@ -285,6 +289,29 @@ export class WorkflowService {
|
|
|
285
289
|
throw new BadRequestException(`Invalid DSL: ${err?.message ?? err}`);
|
|
286
290
|
}
|
|
287
291
|
|
|
292
|
+
// Per-edge sourceHandle validation against node-type outputs declared in the
|
|
293
|
+
// database (t_workflow_node_type.outputs). Hard-fail with a structured error
|
|
294
|
+
// list so the frontend can highlight invalid edges.
|
|
295
|
+
const nodeTypes = await this.prisma.workflowNodeType.findMany({
|
|
296
|
+
select: { type: true, outputs: true },
|
|
297
|
+
});
|
|
298
|
+
const outputsMap: NodeOutputsMap = {};
|
|
299
|
+
for (const nt of nodeTypes) {
|
|
300
|
+
// Pass through: null = dynamic, [] = single anonymous, [..] = enumerated
|
|
301
|
+
outputsMap[nt.type] =
|
|
302
|
+
nt.outputs === undefined
|
|
303
|
+
? []
|
|
304
|
+
: (nt.outputs as unknown as string[] | null);
|
|
305
|
+
}
|
|
306
|
+
const handleErrors = validateDslHandles(dslToPublish as any, outputsMap);
|
|
307
|
+
if (handleErrors.length > 0) {
|
|
308
|
+
throw new BadRequestException({
|
|
309
|
+
message: 'DSL validation failed: invalid edge sourceHandle(s)',
|
|
310
|
+
code: 'DSL_HANDLE_VALIDATION',
|
|
311
|
+
errors: handleErrors,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
288
315
|
// Validate webhook path collisions across other published workflows
|
|
289
316
|
const webhookConfigs = this.extractWebhookConfigs(dslToPublish);
|
|
290
317
|
if (webhookConfigs.length > 0) {
|
|
@@ -78,6 +78,7 @@ describe('WorkflowNodeTypeService', () => {
|
|
|
78
78
|
configSchema: {},
|
|
79
79
|
inputSchema: {},
|
|
80
80
|
outputSchema: {},
|
|
81
|
+
outputs: [],
|
|
81
82
|
description: 'test_description',
|
|
82
83
|
isBuiltin: true,
|
|
83
84
|
},
|
|
@@ -103,6 +104,7 @@ describe('WorkflowNodeTypeService', () => {
|
|
|
103
104
|
configSchema: {},
|
|
104
105
|
inputSchema: {},
|
|
105
106
|
outputSchema: {},
|
|
107
|
+
outputs: [],
|
|
106
108
|
description: 'test_description',
|
|
107
109
|
isBuiltin: true,
|
|
108
110
|
},
|
|
@@ -128,6 +130,7 @@ describe('WorkflowNodeTypeService', () => {
|
|
|
128
130
|
configSchema: {},
|
|
129
131
|
inputSchema: {},
|
|
130
132
|
outputSchema: {},
|
|
133
|
+
outputs: [],
|
|
131
134
|
description: 'test_description',
|
|
132
135
|
isBuiltin: true,
|
|
133
136
|
},
|
|
@@ -139,6 +142,7 @@ describe('WorkflowNodeTypeService', () => {
|
|
|
139
142
|
configSchema: {},
|
|
140
143
|
inputSchema: {},
|
|
141
144
|
outputSchema: {},
|
|
145
|
+
outputs: [],
|
|
142
146
|
description: 'test_description',
|
|
143
147
|
isBuiltin: true,
|
|
144
148
|
},
|
|
@@ -230,6 +234,7 @@ describe('WorkflowNodeTypeService', () => {
|
|
|
230
234
|
configSchema: {},
|
|
231
235
|
inputSchema: {},
|
|
232
236
|
outputSchema: {},
|
|
237
|
+
outputs: [],
|
|
233
238
|
description: 'test_description',
|
|
234
239
|
isBuiltin: true,
|
|
235
240
|
} as any;
|
|
@@ -259,6 +264,7 @@ describe('WorkflowNodeTypeService', () => {
|
|
|
259
264
|
configSchema: {},
|
|
260
265
|
inputSchema: {},
|
|
261
266
|
outputSchema: {},
|
|
267
|
+
outputs: [],
|
|
262
268
|
description: 'test_description',
|
|
263
269
|
isBuiltin: true,
|
|
264
270
|
},
|