Skip to content

Commit c21d4ea

Browse files
committed
refactor: consolidate action graph traversal
1 parent 22eb6c4 commit c21d4ea

4 files changed

Lines changed: 144 additions & 173 deletions

File tree

internal/repository/actionutil/flow_helpers.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ package actionutil
22

33
import "fmt"
44

5+
// Descendants returns every node reachable from root through directed edges.
6+
// Root is excluded unless an edge cycle leads back to it.
7+
func Descendants[ID comparable, E any](root ID, edges []E, endpoints func(E) (ID, ID)) map[ID]bool {
8+
descendants := make(map[ID]bool)
9+
queue := make([]ID, 0)
10+
11+
for _, edge := range edges {
12+
source, target := endpoints(edge)
13+
if source == root && !descendants[target] {
14+
descendants[target] = true
15+
queue = append(queue, target)
16+
}
17+
}
18+
19+
for len(queue) > 0 {
20+
next := queue[0]
21+
queue = queue[1:]
22+
for _, edge := range edges {
23+
source, target := endpoints(edge)
24+
if source == next && !descendants[target] {
25+
descendants[target] = true
26+
queue = append(queue, target)
27+
}
28+
}
29+
}
30+
31+
return descendants
32+
}
33+
534
// ValidateFlowAcyclic returns an error if the directed graph formed by the
635
// given nodes and edges (using their client-side IDs) contains a cycle. It is
736
// intended to run before persisting a flow so users get immediate validation

internal/services/action_engine_iterator.go

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"windshift/internal/models"
1010
"windshift/internal/repository"
11+
"windshift/internal/repository/actionutil"
1112
)
1213

1314
// defaultIteratorMaxItems bounds unconfigured iterator fan-out.
@@ -23,28 +24,9 @@ var errStepBudgetExceeded = fmt.Errorf("action step budget exceeded (%d): nested
2324
// iteratorBodyNodes finds nodes reachable from iterator edges for each emitted
2425
// item. It trusts creation-time validation that no outside edges join the body.
2526
func iteratorBodyNodes(iteratorNodeID int, edges []models.ActionEdge) map[int]bool {
26-
body := map[int]bool{}
27-
queue := []int{}
28-
29-
for _, e := range edges {
30-
if e.SourceNodeID == iteratorNodeID && !body[e.TargetNodeID] {
31-
body[e.TargetNodeID] = true
32-
queue = append(queue, e.TargetNodeID)
33-
}
34-
}
35-
36-
for len(queue) > 0 {
37-
next := queue[0]
38-
queue = queue[1:]
39-
for _, e := range edges {
40-
if e.SourceNodeID == next && !body[e.TargetNodeID] {
41-
body[e.TargetNodeID] = true
42-
queue = append(queue, e.TargetNodeID)
43-
}
44-
}
45-
}
46-
47-
return body
27+
return actionutil.Descendants(iteratorNodeID, edges, func(edge models.ActionEdge) (int, int) {
28+
return edge.SourceNodeID, edge.TargetNodeID
29+
})
4830
}
4931

5032
// runIterator handles a single iterator node: it executes the iterator's

internal/services/actioncatalog/validate.go

Lines changed: 107 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,33 @@ type typedCapabilityResolver interface {
129129
// trigger-config validation; missing node types prevent node-config
130130
// validation for that specific node.
131131
func Validate(c *Catalog, def ActionDefinition, workspaceID int, caps CapabilityResolver) ValidationErrors {
132-
var errs ValidationErrors
132+
errs := validateDefinitionHeader(c, def)
133+
nodeIDsSeen, triggerNodeCount, nodeErrs := validateDefinitionNodes(c, def.Nodes, workspaceID, caps)
134+
errs = append(errs, nodeErrs...)
135+
if len(def.Nodes) > 0 && triggerNodeCount > 1 {
136+
errs = append(errs, ValidationError{
137+
Code: CodeMultipleTriggers,
138+
Message: "Action graph must contain at most one trigger node",
139+
Path: "nodes",
140+
})
141+
}
133142

134-
// --- Top-level required fields ---------------------------------------
143+
errs = append(errs, validateDefinitionEdges(nodeIDsSeen, def.Edges)...)
144+
if !errs.Has(CodeUnknownNodeID) && !errs.Has(CodeDuplicateNodeID) {
145+
errs = append(errs, validateDefinitionGraph(def.Nodes, def.Edges)...)
146+
}
147+
return errs
148+
}
149+
150+
func validateDefinitionHeader(c *Catalog, def ActionDefinition) ValidationErrors {
151+
var errs ValidationErrors
135152
if strings.TrimSpace(def.Name) == "" {
136153
errs = append(errs, ValidationError{Code: CodeRequired, Message: "Name is required", Path: "name"})
137154
}
138155
if string(def.TriggerType) == "" {
139156
errs = append(errs, ValidationError{Code: CodeRequired, Message: "Trigger type is required", Path: "trigger_type"})
140157
}
141158

142-
// --- Trigger type & config -------------------------------------------
143159
if def.TriggerType != "" {
144160
trig := c.Trigger(def.TriggerType)
145161
if trig == nil {
@@ -158,11 +174,12 @@ func Validate(c *Catalog, def ActionDefinition, workspaceID int, caps Capability
158174
}
159175
}
160176
}
177+
return errs
178+
}
161179

162-
// --- Nodes: types + per-node config schema ---------------------------
163-
nodeIDsSeen := make(map[int]bool, len(def.Nodes))
164-
triggerNodeCount := 0
165-
for i, n := range def.Nodes {
180+
func validateDefinitionNodes(c *Catalog, nodes []models.ActionNode, workspaceID int, caps CapabilityResolver) (nodeIDsSeen map[int]bool, triggerNodeCount int, errs ValidationErrors) {
181+
nodeIDsSeen = make(map[int]bool, len(nodes))
182+
for i, n := range nodes {
166183
path := fmt.Sprintf("nodes[%d]", i)
167184
if n.ID != 0 && nodeIDsSeen[n.ID] {
168185
errs = append(errs, ValidationError{
@@ -177,125 +194,109 @@ func Validate(c *Catalog, def ActionDefinition, workspaceID int, caps Capability
177194
if n.NodeType == models.ActionNodeTrigger {
178195
triggerNodeCount++
179196
}
180-
meta := c.Node(n.NodeType)
181-
if meta == nil {
182-
errs = append(errs, ValidationError{
183-
Code: CodeUnknownNodeType,
184-
Message: fmt.Sprintf("Unknown node type %q", n.NodeType),
185-
Path: path + ".node_type",
186-
})
187-
continue
188-
}
189-
if err := validateConfigJSON(meta.resolved, n.NodeConfig); err != nil {
197+
errs = append(errs, validateDefinitionNode(c, n, path, workspaceID, caps)...)
198+
}
199+
return nodeIDsSeen, triggerNodeCount, errs
200+
}
201+
202+
func validateDefinitionNode(c *Catalog, node models.ActionNode, path string, workspaceID int, caps CapabilityResolver) ValidationErrors {
203+
meta := c.Node(node.NodeType)
204+
if meta == nil {
205+
return ValidationErrors{{
206+
Code: CodeUnknownNodeType,
207+
Message: fmt.Sprintf("Unknown node type %q", node.NodeType),
208+
Path: path + ".node_type",
209+
}}
210+
}
211+
if err := validateConfigJSON(meta.resolved, node.NodeConfig); err != nil {
212+
return ValidationErrors{{
213+
Code: schemaErrCode(err),
214+
Message: err.Error(),
215+
Path: path + ".node_config",
216+
}}
217+
}
218+
if msg, field := validateNodeConfigValues(node); msg != "" {
219+
return ValidationErrors{{
220+
Code: CodeInvalidConfig,
221+
Message: msg,
222+
Path: path + ".node_config." + field,
223+
}}
224+
}
225+
if caps == nil || workspaceID <= 0 {
226+
return nil
227+
}
228+
229+
var errs ValidationErrors
230+
if capID, field := capabilityRef(node); capID > 0 && !nodeCapabilityAvailable(caps, workspaceID, capID, node.NodeType) {
231+
errs = append(errs, ValidationError{
232+
Code: CodeUnknownCapability,
233+
Message: fmt.Sprintf("Capability %d is not available to this workspace or has the wrong type", capID),
234+
Path: path + ".node_config." + field,
235+
})
236+
}
237+
if node.NodeType != models.ActionNodeAIAgent {
238+
return errs
239+
}
240+
241+
var config models.AIAgentNodeConfig
242+
_ = json.Unmarshal([]byte(node.NodeConfig), &config)
243+
for i, rawID := range config.Tools {
244+
capID, err := strconv.Atoi(rawID)
245+
if err != nil || capID <= 0 {
190246
errs = append(errs, ValidationError{
191-
Code: schemaErrCode(err),
192-
Message: err.Error(),
193-
Path: path + ".node_config",
247+
Code: CodeInvalidConfig,
248+
Message: fmt.Sprintf("Tool capability %q is not a valid capability ID", rawID),
249+
Path: fmt.Sprintf("%s.node_config.tools[%d]", path, i),
194250
})
195-
// Skip capability check if config itself is broken — the field
196-
// we'd inspect might not even exist on the parsed shape.
197251
continue
198252
}
199-
if msg, field := validateNodeConfigValues(n); msg != "" {
253+
if !toolCapabilityAvailable(caps, workspaceID, capID) {
200254
errs = append(errs, ValidationError{
201-
Code: CodeInvalidConfig,
202-
Message: msg,
203-
Path: path + ".node_config." + field,
255+
Code: CodeUnknownCapability,
256+
Message: fmt.Sprintf("Tool capability %d is not available to this workspace or is not an http_client capability", capID),
257+
Path: fmt.Sprintf("%s.node_config.tools[%d]", path, i),
204258
})
205-
continue
206259
}
207-
if caps != nil && workspaceID > 0 {
208-
if capID, field := capabilityRef(n); capID > 0 {
209-
if !nodeCapabilityAvailable(caps, workspaceID, capID, n.NodeType) {
210-
errs = append(errs, ValidationError{
211-
Code: CodeUnknownCapability,
212-
Message: fmt.Sprintf("Capability %d is not available to this workspace or has the wrong type", capID),
213-
Path: path + ".node_config." + field,
214-
})
215-
}
216-
}
217-
if n.NodeType == models.ActionNodeAIAgent {
218-
var cfg models.AIAgentNodeConfig
219-
_ = json.Unmarshal([]byte(n.NodeConfig), &cfg)
220-
for j, rawID := range cfg.Tools {
221-
capID, err := strconv.Atoi(rawID)
222-
if err != nil || capID <= 0 {
223-
errs = append(errs, ValidationError{
224-
Code: CodeInvalidConfig,
225-
Message: fmt.Sprintf("Tool capability %q is not a valid capability ID", rawID),
226-
Path: fmt.Sprintf("%s.node_config.tools[%d]", path, j),
227-
})
228-
continue
229-
}
230-
if !toolCapabilityAvailable(caps, workspaceID, capID) {
231-
errs = append(errs, ValidationError{
232-
Code: CodeUnknownCapability,
233-
Message: fmt.Sprintf("Tool capability %d is not available to this workspace or is not an http_client capability", capID),
234-
Path: fmt.Sprintf("%s.node_config.tools[%d]", path, j),
235-
})
236-
}
237-
}
238-
}
239-
}
240-
}
241-
242-
if len(def.Nodes) > 0 && triggerNodeCount > 1 {
243-
errs = append(errs, ValidationError{
244-
Code: CodeMultipleTriggers,
245-
Message: "Action graph must contain at most one trigger node",
246-
Path: "nodes",
247-
})
248260
}
261+
return errs
262+
}
249263

250-
// --- Edges: source/target reference known nodes ----------------------
251-
for i, e := range def.Edges {
264+
func validateDefinitionEdges(nodeIDs map[int]bool, edges []models.ActionEdge) ValidationErrors {
265+
var errs ValidationErrors
266+
for i, edge := range edges {
252267
path := fmt.Sprintf("edges[%d]", i)
253-
if e.SourceNodeID == 0 || !nodeIDsSeen[e.SourceNodeID] {
268+
if edge.SourceNodeID == 0 || !nodeIDs[edge.SourceNodeID] {
254269
errs = append(errs, ValidationError{
255270
Code: CodeUnknownNodeID,
256-
Message: fmt.Sprintf("Edge source_node_id %d does not match any node", e.SourceNodeID),
271+
Message: fmt.Sprintf("Edge source_node_id %d does not match any node", edge.SourceNodeID),
257272
Path: path + ".source_node_id",
258273
})
259274
}
260-
if e.TargetNodeID == 0 || !nodeIDsSeen[e.TargetNodeID] {
275+
if edge.TargetNodeID == 0 || !nodeIDs[edge.TargetNodeID] {
261276
errs = append(errs, ValidationError{
262277
Code: CodeUnknownNodeID,
263-
Message: fmt.Sprintf("Edge target_node_id %d does not match any node", e.TargetNodeID),
278+
Message: fmt.Sprintf("Edge target_node_id %d does not match any node", edge.TargetNodeID),
264279
Path: path + ".target_node_id",
265280
})
266281
}
267282
}
283+
return errs
284+
}
268285

269-
// --- Graph-level invariants (cycles, ambiguous flow, iterator body) --
270-
if !errs.Has(CodeUnknownNodeID) && !errs.Has(CodeDuplicateNodeID) {
271-
if err := actionutil.ValidateFlowAcyclic[
272-
models.ActionNode, *models.ActionNode,
273-
models.ActionEdge, *models.ActionEdge,
274-
](def.Nodes, def.Edges); err != nil {
275-
errs = append(errs, ValidationError{
276-
Code: CodeFlowCycle,
277-
Message: err.Error(),
278-
Path: "edges",
279-
})
280-
}
281-
282-
if msg := validateNonTriggerAmbiguity(def.Nodes, def.Edges); msg != "" {
283-
errs = append(errs, ValidationError{
284-
Code: CodeAmbiguousFlow,
285-
Message: msg,
286-
Path: "edges",
287-
})
288-
}
289-
290-
if leak := validateIteratorBodies(def.Nodes, def.Edges); leak != "" {
291-
errs = append(errs, ValidationError{
292-
Code: CodeIteratorBodyLeak,
293-
Message: leak,
294-
Path: "edges",
295-
})
296-
}
286+
func validateDefinitionGraph(nodes []models.ActionNode, edges []models.ActionEdge) ValidationErrors {
287+
var errs ValidationErrors
288+
if err := actionutil.ValidateFlowAcyclic[
289+
models.ActionNode, *models.ActionNode,
290+
models.ActionEdge, *models.ActionEdge,
291+
](nodes, edges); err != nil {
292+
errs = append(errs, ValidationError{Code: CodeFlowCycle, Message: err.Error(), Path: "edges"})
293+
}
294+
if msg := validateNonTriggerAmbiguity(nodes, edges); msg != "" {
295+
errs = append(errs, ValidationError{Code: CodeAmbiguousFlow, Message: msg, Path: "edges"})
296+
}
297+
if leak := validateIteratorBodies(nodes, edges); leak != "" {
298+
errs = append(errs, ValidationError{Code: CodeIteratorBodyLeak, Message: leak, Path: "edges"})
297299
}
298-
299300
return errs
300301
}
301302

@@ -377,7 +378,9 @@ func validateIteratorBodies(nodes []models.ActionNode, edges []models.ActionEdge
377378
if !n.NodeType.IsIterator() {
378379
continue
379380
}
380-
body := iteratorBodyClosure(n.ID, edges)
381+
body := actionutil.Descendants(n.ID, edges, func(edge models.ActionEdge) (int, int) {
382+
return edge.SourceNodeID, edge.TargetNodeID
383+
})
381384
for _, e := range edges {
382385
if !body[e.TargetNodeID] {
383386
continue
@@ -393,28 +396,6 @@ func validateIteratorBodies(nodes []models.ActionNode, edges []models.ActionEdge
393396
return ""
394397
}
395398

396-
func iteratorBodyClosure(iteratorID int, edges []models.ActionEdge) map[int]bool {
397-
body := map[int]bool{}
398-
queue := []int{}
399-
for _, e := range edges {
400-
if e.SourceNodeID == iteratorID && !body[e.TargetNodeID] {
401-
body[e.TargetNodeID] = true
402-
queue = append(queue, e.TargetNodeID)
403-
}
404-
}
405-
for len(queue) > 0 {
406-
next := queue[0]
407-
queue = queue[1:]
408-
for _, e := range edges {
409-
if e.SourceNodeID == next && !body[e.TargetNodeID] {
410-
body[e.TargetNodeID] = true
411-
queue = append(queue, e.TargetNodeID)
412-
}
413-
}
414-
}
415-
return body
416-
}
417-
418399
// validateNodeConfigValues runs value-level checks that can't be expressed
419400
// in the JSON schema reflected from the config struct (the jsonschema-go
420401
// library only supports schema *shape*, not numeric bounds via struct tags).

0 commit comments

Comments
 (0)