Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/project/doc.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package project loads the root project.yaml, expands spec.imports, merges resources
// into a spec.ProjectGraph, and can resolve symbolic references via ResolveReferences.
// into a spec.ProjectGraph. Reference checks use [ResolveReferences] (delegates to spec);
// full MVP validation is [spec.ValidateProjectGraph].
package project
14 changes: 2 additions & 12 deletions internal/project/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,8 @@ import (
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// MissingRefError reports a reference from Referrer to a target kind/name that is not in the graph.
type MissingRefError struct {
Referrer spec.ResourceID
Missing spec.ResourceID
}

func (e *MissingRefError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("%s references missing %s", e.Referrer.String(), e.Missing.String())
}
// MissingRefError is reported when a graph reference does not resolve (see [spec.MissingRefError]).
type MissingRefError = spec.MissingRefError

// DuplicateResourceError is returned when two files define the same kind/name (§9.1).
type DuplicateResourceError struct {
Expand Down
94 changes: 5 additions & 89 deletions internal/project/graph.go
Original file line number Diff line number Diff line change
@@ -1,95 +1,11 @@
package project

import (
"strings"
import "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// RefIndex summarizes symbolic references between resources before resolution (issue #6).
// Names are metadata.name values; edges point to target kinds as implied by the field.
type RefIndex struct {
// AgentName -> tool names from Agent.spec.tools
AgentTools map[string][]string
// AgentName -> policy name when Agent.spec.policy is non-empty
AgentPolicies map[string]string
// WorkflowName -> agent names from steps with agent: set
WorkflowAgents map[string][]string
// WorkflowName -> tool names from steps with uses: tool.<name>...
WorkflowTools map[string][]string
// WorkflowName -> policy name when Workflow.spec.policy is non-empty
WorkflowPolicies map[string]string
}
// RefIndex summarizes symbolic references between resources (see [spec.RefIndex]).
type RefIndex = spec.RefIndex

// BuildRefIndex scans ProjectGraph resources and builds RefIndex lookup tables.
func BuildRefIndex(g *spec.ProjectGraph) *RefIndex {
if g == nil {
return &RefIndex{
AgentTools: map[string][]string{},
AgentPolicies: map[string]string{},
WorkflowAgents: map[string][]string{},
WorkflowTools: map[string][]string{},
WorkflowPolicies: map[string]string{},
}
}
ix := &RefIndex{
AgentTools: make(map[string][]string),
AgentPolicies: make(map[string]string),
WorkflowAgents: make(map[string][]string),
WorkflowTools: make(map[string][]string),
WorkflowPolicies: make(map[string]string),
}
for name, ar := range g.Agents {
if ar == nil {
continue
}
var tools []string
for _, t := range ar.Spec.Tools {
if s := strings.TrimSpace(t); s != "" {
tools = append(tools, s)
}
}
ix.AgentTools[name] = dedupeStrings(tools)
if p := strings.TrimSpace(ar.Spec.Policy); p != "" {
ix.AgentPolicies[name] = p
}
}
for name, wr := range g.Workflows {
if wr == nil {
continue
}
if p := strings.TrimSpace(wr.Spec.Policy); p != "" {
ix.WorkflowPolicies[name] = p
}
var agents, tools []string
for _, st := range wr.Spec.Steps {
if a := strings.TrimSpace(st.Agent); a != "" {
agents = append(agents, a)
}
if u := strings.TrimSpace(st.Uses); u != "" {
if tn, ok := spec.ParseToolUses(u); ok {
tools = append(tools, tn)
}
}
}
ix.WorkflowAgents[name] = dedupeStrings(agents)
ix.WorkflowTools[name] = dedupeStrings(tools)
}
return ix
}

func dedupeStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
var out []string
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
func BuildRefIndex(g *spec.ProjectGraph) *spec.RefIndex {
return spec.BuildRefIndex(g)
}
125 changes: 3 additions & 122 deletions internal/project/resolver.go
Original file line number Diff line number Diff line change
@@ -1,127 +1,8 @@
package project

import (
"fmt"
"strings"
import "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"

"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
)

// ResolveReferences checks that symbolic references in the merged graph resolve to
// existing resources (§9.1) and applies simple sequential workflow rules (§9.4).
// ResolveReferences checks symbolic references and workflow step rules (§9.1, §9.4).
func ResolveReferences(g *spec.ProjectGraph) error {
if g == nil {
return nil
}
ix := BuildRefIndex(g)

for agentName, tools := range ix.AgentTools {
for _, tn := range tools {
if _, ok := g.Tools[tn]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
}
}
}
}
for agentName, pol := range ix.AgentPolicies {
if _, ok := g.Policies[pol]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindAgent, Name: agentName},
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
}
}
}

for wfName, wr := range g.Workflows {
if wr == nil {
continue
}
if err := validateWorkflowSteps(wfName, &wr.Spec); err != nil {
return err
}
for _, an := range ix.WorkflowAgents[wfName] {
if _, ok := g.Agents[an]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
Missing: spec.ResourceID{Kind: spec.KindAgent, Name: an},
}
}
}
for _, tn := range ix.WorkflowTools[wfName] {
if _, ok := g.Tools[tn]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
Missing: spec.ResourceID{Kind: spec.KindTool, Name: tn},
}
}
}
if pol := ix.WorkflowPolicies[wfName]; pol != "" {
if _, ok := g.Policies[pol]; !ok {
return &MissingRefError{
Referrer: spec.ResourceID{Kind: spec.KindWorkflow, Name: wfName},
Missing: spec.ResourceID{Kind: spec.KindPolicy, Name: pol},
}
}
}
if err := validateWorkflowStepOrder(wfName, &wr.Spec); err != nil {
return err
}
}
return nil
}

func validateWorkflowSteps(wfName string, w *spec.WorkflowSpec) error {
seenID := make(map[string]struct{})
for _, st := range w.Steps {
sid := strings.TrimSpace(st.ID)
if sid != "" {
if _, dup := seenID[sid]; dup {
return fmt.Errorf("workflow %s: duplicate step id %q", wfName, sid)
}
seenID[sid] = struct{}{}
}
hasA := strings.TrimSpace(st.Agent) != ""
hasU := strings.TrimSpace(st.Uses) != ""
if hasA && hasU {
return fmt.Errorf("workflow %s step %q: cannot set both agent and uses", wfName, sid)
}
if !hasA && !hasU {
return fmt.Errorf("workflow %s step %q: must set exactly one of agent or uses", wfName, sid)
}
if hasU {
u := strings.TrimSpace(st.Uses)
if _, ok := spec.ParseToolUses(u); !ok {
return fmt.Errorf("workflow %s step %q: unsupported uses %q (expected tool.<name>...)", wfName, sid, u)
}
}
}
return nil
}

func validateWorkflowStepOrder(wfName string, w *spec.WorkflowSpec) error {
idToIdx := make(map[string]int)
for i, st := range w.Steps {
id := strings.TrimSpace(st.ID)
if id == "" {
continue
}
idToIdx[id] = i
}
for i, st := range w.Steps {
sid := strings.TrimSpace(st.ID)
for _, sval := range spec.CollectWithStringValues(st.With) {
for _, dep := range spec.InterpolationStepRefs(sval) {
j, ok := idToIdx[dep]
if !ok {
return fmt.Errorf("workflow %s step %q: interpolation references unknown step %q", wfName, sid, dep)
}
if j >= i {
return fmt.Errorf("workflow %s step %q: forward reference to steps.%s (§9.4)", wfName, sid, dep)
}
}
}
}
return nil
return spec.ResolveReferences(g)
}
4 changes: 2 additions & 2 deletions internal/spec/doc.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Package spec defines resource envelopes, MVP kind structs (§6–§7), YAML loading,
// and project-level default application (§7.1) via NormalizeProjectGraph.
// Reference resolution, environment overrides, and semantic validation are future work.
// project-level defaults ([NormalizeProjectGraph]), reference resolution ([ResolveReferences]),
// and graph validation ([ValidateProjectGraph], §9.1–§9.5 MVP subset).
package spec
85 changes: 85 additions & 0 deletions internal/spec/refindex.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package spec

import "strings"

// RefIndex summarizes symbolic references between resources (issue #6, §9.1).
type RefIndex struct {
AgentTools map[string][]string
AgentPolicies map[string]string
WorkflowAgents map[string][]string
WorkflowTools map[string][]string
WorkflowPolicies map[string]string
}

// BuildRefIndex scans ProjectGraph resources and builds RefIndex lookup tables.
func BuildRefIndex(g *ProjectGraph) *RefIndex {
if g == nil {
return &RefIndex{
AgentTools: map[string][]string{},
AgentPolicies: map[string]string{},
WorkflowAgents: map[string][]string{},
WorkflowTools: map[string][]string{},
WorkflowPolicies: map[string]string{},
}
}
ix := &RefIndex{
AgentTools: make(map[string][]string),
AgentPolicies: make(map[string]string),
WorkflowAgents: make(map[string][]string),
WorkflowTools: make(map[string][]string),
WorkflowPolicies: make(map[string]string),
}
for name, ar := range g.Agents {
if ar == nil {
continue
}
var tools []string
for _, t := range ar.Spec.Tools {
if s := strings.TrimSpace(t); s != "" {
tools = append(tools, s)
}
}
ix.AgentTools[name] = dedupeRefStrings(tools)
if p := strings.TrimSpace(ar.Spec.Policy); p != "" {
ix.AgentPolicies[name] = p
}
}
for name, wr := range g.Workflows {
if wr == nil {
continue
}
if p := strings.TrimSpace(wr.Spec.Policy); p != "" {
ix.WorkflowPolicies[name] = p
}
var agents, tools []string
for _, st := range wr.Spec.Steps {
if a := strings.TrimSpace(st.Agent); a != "" {
agents = append(agents, a)
}
if u := strings.TrimSpace(st.Uses); u != "" {
if tn, ok := ParseToolUses(u); ok {
tools = append(tools, tn)
}
}
}
ix.WorkflowAgents[name] = dedupeRefStrings(agents)
ix.WorkflowTools[name] = dedupeRefStrings(tools)
}
return ix
}

func dedupeRefStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
var out []string
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
Loading
Loading