@@ -129,17 +129,33 @@ type typedCapabilityResolver interface {
129129// trigger-config validation; missing node types prevent node-config
130130// validation for that specific node.
131131func 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