@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.
Files changed (43) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/DESIGN.md +348 -0
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/PRODUCT.md +75 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +5 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +24 -1
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +32 -3
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.service.ts +28 -6
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.spec.ts +220 -0
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-dsl-validate.ts +129 -0
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.dto.ts +1 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-export.service.ts +4 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +46 -0
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +27 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeType/workflowNodeType.service.spec.ts +6 -0
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/node-types.ts +43 -4
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/dsl/validate.ts +109 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/validate.test.ts +205 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/web/package.json +1 -1
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/promptGenerator.ts +19 -3
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/header.tsx +55 -56
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/layout.tsx +7 -3
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/logo.tsx +7 -1
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/sider.tsx +179 -160
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/layout/title.tsx +34 -31
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/constants/layout.ts +24 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +24 -2
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +24 -2
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +173 -1
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +27 -2
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasApi.ts +29 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasConfigRegistry.tsx +2 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasContextMenuRegistry.tsx +98 -3
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/components/TableConfigModal.tsx +192 -0
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/utils/tableCodeUtils.ts +338 -0
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/CustomNode.tsx +66 -51
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/EnhancedFlowRenderer.tsx +7 -1
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ExecutionStatusNode.tsx +66 -26
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/FlowRenderer.tsx +7 -1
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +9 -2
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/hooks/useWorkflowAgent.ts +30 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/show.tsx +9 -2
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +1 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/resolveOutputs.ts +27 -0
  43. 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(private readonly canvasService: CanvasService) {}
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
- return this.canvasService.findOne(id, req.user.userid);
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
- return this.canvasService.update(id, req.user.userid, dto);
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: string) {
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: { userId, name: dto.name, NOT: { id } },
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
+ }
@@ -33,6 +33,7 @@ export interface ExportPayload {
33
33
  configSchema: any;
34
34
  inputSchema: any;
35
35
  outputSchema: any;
36
+ outputs: any;
36
37
  isBuiltin: boolean;
37
38
  }>;
38
39
  }
@@ -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
  },