feat(gateway): DSL 拓扑校验 —— 前置拦截坏图(T4.E)

- ParseAndAssemble 加 validateTopology:拦重复/空节点 id + 悬挂边
  (source/target 指向不存在节点),避免坏图进编排后被 compose 静默跳过
- 保守策略:空图 / 报告任务 {topic} / 非标准载荷一律宽松放过,不误伤合法提交
- 单测覆盖合法/重复id/空id/悬挂边×2/空端点/放过场景
- live:悬挂边 POST /tasks → 422(错误指明具体边与缺失节点);合法图 → 202

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-02 10:14:31 +08:00
parent 4bec95fde1
commit 65e939889e
3 changed files with 87 additions and 2 deletions
+47 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/sundynix/sundynix-shared/contract"
)
@@ -20,7 +21,11 @@ func ParseAndAssemble(raw json.RawMessage) (*contract.Task, error) {
if err := json.Unmarshal(raw, &probe); err != nil {
return nil, errors.New("invalid dsl json: " + err.Error())
}
// TODO: 节点拓扑校验 / 节点-工具映射
// 拓扑校验:前置拦截明确的结构错误(重复/空 id、悬挂边),
// 避免坏图进编排后被 compose 静默跳过(dangling edge 会被 nodeByID 检查悄悄丢弃)。
if err := validateTopology(raw); err != nil {
return nil, err
}
return &contract.Task{
ID: newID(),
Graph: raw,
@@ -28,6 +33,47 @@ func ParseAndAssemble(raw json.RawMessage) (*contract.Task, error) {
}, nil
}
// validateTopology 做保守的拓扑校验:只拦不含糊的结构错误,对空图/非标准图(如报告任务的
// {topic:...})与无法按图结构解析的载荷一律宽松放过,杜绝误伤合法提交。
// - 节点 id 不得为空、不得重复
// - 每条边的 source/target 不得为空、且必须指向存在的节点(悬挂边直接拒)
func validateTopology(raw json.RawMessage) error {
var g struct {
Nodes []struct {
ID string `json:"id"`
} `json:"nodes"`
Edges []struct {
Source string `json:"source"`
Target string `json:"target"`
} `json:"edges"`
}
if err := json.Unmarshal(raw, &g); err != nil {
return nil // 非标准图结构(字段类型不匹配)→ 跳过拓扑校验,不误伤
}
ids := make(map[string]bool, len(g.Nodes))
for i, n := range g.Nodes {
if n.ID == "" {
return fmt.Errorf("dsl 校验失败:节点[%d] 缺少 id", i)
}
if ids[n.ID] {
return fmt.Errorf("dsl 校验失败:节点 id 重复:%s", n.ID)
}
ids[n.ID] = true
}
for i, e := range g.Edges {
if e.Source == "" || e.Target == "" {
return fmt.Errorf("dsl 校验失败:边[%d] 的 source/target 不能为空", i)
}
if !ids[e.Source] {
return fmt.Errorf("dsl 校验失败:边[%d] 引用不存在的源节点:%s", i, e.Source)
}
if !ids[e.Target] {
return fmt.Errorf("dsl 校验失败:边[%d] 引用不存在的目标节点:%s", i, e.Target)
}
}
return nil
}
func newID() string {
var b [8]byte
_, _ = rand.Read(b[:])
@@ -31,3 +31,41 @@ func TestParseAndAssemble_Errors(t *testing.T) {
t.Error("非法 JSON 应报错")
}
}
func TestParseAndAssemble_Topology(t *testing.T) {
ok := func(raw string) {
t.Helper()
if _, err := ParseAndAssemble(json.RawMessage(raw)); err != nil {
t.Errorf("应通过: %s\n err=%v", raw, err)
}
}
bad := func(raw, wantSub string) {
t.Helper()
_, err := ParseAndAssemble(json.RawMessage(raw))
if err == nil {
t.Errorf("应被拒: %s", raw)
return
}
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("错误应含 %q, got %q", wantSub, err.Error())
}
}
// 合法:多节点 + 边引用齐全。
ok(`{"nodes":[{"id":"a","kind":"input"},{"id":"b","kind":"agent"}],"edges":[{"source":"a","target":"b"}]}`)
// 宽松放过:空图 / 报告任务的 {topic} / 无 nodes 字段。
ok(`{"nodes":[],"edges":[]}`)
ok(`{"topic":"季度报告"}`)
ok(`{"version":"1"}`)
// 拒绝:重复 id。
bad(`{"nodes":[{"id":"a"},{"id":"a"}],"edges":[]}`, "重复")
// 拒绝:空 id。
bad(`{"nodes":[{"id":""}],"edges":[]}`, "缺少 id")
// 拒绝:悬挂边(目标节点不存在)。
bad(`{"nodes":[{"id":"a"}],"edges":[{"source":"a","target":"ghost"}]}`, "不存在的目标节点")
// 拒绝:悬挂边(源节点不存在,即便无节点)。
bad(`{"nodes":[],"edges":[{"source":"x","target":"y"}]}`, "不存在的源节点")
// 拒绝:边端点为空。
bad(`{"nodes":[{"id":"a"}],"edges":[{"source":"a","target":""}]}`, "不能为空")
}