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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ test/results/
# runs/<topic-slug>-<YYYYMMDD>/ — gitignored so report drafts never
# leak into commits.
runs/

# Operator dev tooling: controller-gen, kustomize, envtest binaries
# land here when the operator/Makefile downloads them. Refetched per
# developer.
operator/bin/
107 changes: 59 additions & 48 deletions openspec/changes/20260508-operator-feature-parity-d-e/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,54 +12,65 @@

## PR-49 — API & CRD foundations

**Branch suggestion**: `feat/op-pr49-api-foundations`
**PR slot claimed by**: `-`
**Status**: `unstarted`

- [ ] **Fix the `}` bug** in `operator/api/v1alpha1/agentcollective_types.go:91-94`
(`RoleDefinition` struct missing closing brace before `AgentRoleSpec`). After the fix,
`make generate` should be a no-op against the existing `zz_generated.deepcopy.go`,
proving the source matches the generated shape. *— claimed by: -*

- [ ] **Loosen the role enum**. In `operator/api/v1alpha1/common_types.go` replace the
`// +kubebuilder:validation:Enum=ingester;...` marker on `AgentRole` with
`Pattern=^[a-z][a-z0-9_]{1,62}$` + `MinLength=2` + `MaxLength=63`. Drop the redundant
field-level `Enum=` markers in `agentcollective_types.go` on `AgentRoleSpec.Role` and
`RoleScalingSpec.Role`. *— claimed by: -*

- [ ] **Append exported role consts** in `common_types.go` for the 11 new personas plus
`RoleCodingAgent` umbrella: `RoleCodingArchitect`, `RoleCodingDependency`,
`RoleCodingImplementer`, `RoleCodingReviewer`, `RoleCodingTester`, `RoleResearchPlanner`,
`RoleResearchStrategist`, `RoleResearchEconomist`, `RoleResearchCompetitor`,
`RoleResearchSynthesizer`, `RoleResearchCritic`. *— claimed by: -*

- [ ] **Add `MCPServerSpec` + status types** in `operator/api/v1alpha1/agentcorpus_types.go`.
Fields: `Name, Image, Replicas, Port, Env, SecretEnv, ShmSizeMi, Resources`. Status
type: `MCPServerStatus{Ready bool; Replicas int32; ServiceURL string}`. Add
`Status.MCPServerStatuses map[string]MCPServerStatus`. *— claimed by: -*

- [ ] **Add `ManifestDelivery` field** in `agentcorpus_types.go`:
`// +kubebuilder:validation:Enum=all;none` + `// +kubebuilder:default=all`. *— claimed by: -*

- [ ] **Implement role catalogue**: create `operator/internal/rolecatalogue/catalogue.go`
with `var KnownRoles map[string]struct{}` populated via `go:embed`-baked listing of
`roles/*/role.yaml`. Add the generator at `operator/hack/gen-catalogue.go` triggered by
`//go:generate`. *— claimed by: -*

- [ ] **Add `AgentCollective` validating webhook** at
`operator/api/v1alpha1/agentcollective_webhook.go` (parallel to existing
`agentcorpus_webhook.go`). Reject roles not in `KnownRoles` with closest-match
suggestions. *— claimed by: -*

- [ ] **Run `make generate manifests`** and commit the regenerated
`zz_generated.deepcopy.go` and `config/crd/bases/*.yaml` deltas. *— claimed by: -*

- [ ] **Unit tests** at `operator/test/unit/role_catalogue_test.go` covering catalogue
membership and the closest-match suggestion path. *— claimed by: -*

- [ ] **Verify backwards compat**: `kubectl apply --dry-run=server -f
config/samples/acc_v1alpha1_agentcorpus_standalone.yaml` succeeds; same for `_rhoai`.
*— claimed by: -*
**Branch**: `feat/op-pr49-api-foundations`
**PR**: https://github.com/flg77/acc/pull/49 (draft → ready 2026-05-09)
**PR slot claimed by**: `acc1` (host 10.199.12.91 — Claude instance)
**Status**: `ready-for-review` since 2026-05-09

- [x] **Fix the `}` bug** in `operator/api/v1alpha1/agentcollective_types.go:91-94`
(`RoleDefinition` struct missing closing brace before `AgentRoleSpec`). Landed in
commit `466273b`. *— claimed by: acc1*

- [x] **Loosen the role enum** in `operator/api/v1alpha1/common_types.go`: replaced
closed `Enum=` with `Pattern=^[a-z][a-z0-9_]{1,62}$` + length bounds; dropped
field-level `Enum=` markers on `AgentRoleSpec.Role` and `RoleScalingSpec.Role`.
Landed in commit `9052391`. *— claimed by: acc1*

- [x] **Append exported role consts** in `common_types.go` for 12 personas (5
coding-split + 6 research + umbrella `RoleCodingAgent`). Landed in commit
`9052391`. *— claimed by: acc1*

- [x] **Add `MCPServerSpec` + status types** in `agentcorpus_types.go` —
`Name/Image/Replicas/Port/Env/SecretEnv/ShmSizeMi/Resources` + matching status type
+ `Status.MCPServerStatuses` map. Landed in commit `d9c2784`. *— claimed by: acc1*

- [x] **Add `ManifestDelivery` field** — `Enum=all;none`, default `all`. Landed in
commit `d9c2784`. *— claimed by: acc1*

- [x] **Implement role catalogue** at `operator/internal/rolecatalogue/`. Public API:
`IsKnown / All / Suggest`. Source via `//go:embed known_roles.txt`; generator at
`operator/hack/gen-catalogue.go` triggered by `//go:generate`. 47 roles seeded.
Landed in commit `1f7c3d…` *(pre-rebase hash, see git log)*. *— claimed by: acc1*

- [x] **Add `AgentCollective` validating webhook** at `agentcollective_webhook.go`
with closest-match Levenshtein suggestions; bonus validation for
`roleScaling[*].role` declared-in-agents check, minReplicas≤maxReplicas, and
llm sub-struct presence. Also extended `agentcorpus_webhook.go`: defaults
`manifestDelivery=all`, defaults `MCPServer.Replicas=1` and `Port=8080`,
rejects duplicate MCP server names. Landed in commit `d2043d0`.
*— claimed by: acc1*

- [x] **Run `make manifests generate`** — regenerated `zz_generated.deepcopy.go`
(+632/-238), `config/crd/bases/acc.redhat.io_agent{collectives,corpora}.yaml`
(the role enum opens up; the new MCP/manifest fields appear), and
`config/webhook/manifests.yaml` (AgentCollective mutating + validating webhooks
registered). Landed in commit `1d61a17`. Includes a build-hygiene side commit
`24e5414` that repaired a stale kube-openapi pseudo-version in `go.mod` and
generated a missing `go.sum` so `go vet/build/test` and `make generate` could
run at all. *— claimed by: acc1*

- [x] **Unit tests** at `operator/test/unit/role_catalogue_test.go` — 12 test
functions covering catalogue membership for legacy + new personas, sorted /
unique / mutation-isolated `All()`, Suggest typo recognition for 6 realistic
inputs, n-cap, n≤0 contract, and distance cutoff. All pass; full suite clean.
Landed in commit `b325344`. *— claimed by: acc1*

- [x] **Verify backwards compat** — `go vet ./...`, `go build ./...`,
`go test ./test/unit/...` all clean. Regex `^[a-z][a-z0-9_]{1,62}$` accepts
every role in both legacy samples (`standalone` + `rhoai`) and every new
persona const. Live `kubectl apply` skipped in favour of regex verification
to avoid mutating the shared cluster — see PR #49 description.
*— claimed by: acc1*

---

Expand Down
8 changes: 5 additions & 3 deletions operator/api/v1alpha1/agentcollective_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ type RoleDefinition struct {
// +kubebuilder:default="0.1.0"
// +optional
Version string `json:"version,omitempty"`
}

// AgentRoleSpec defines the deployment configuration for one agent role.
type AgentRoleSpec struct {
// Role identifies the ACC agent role.
// +kubebuilder:validation:Enum=ingester;analyst;synthesizer;arbiter;observer
// Role identifies the ACC agent role. Schema-level validation is the
// regex pattern on the AgentRole type; semantic validation against the
// operator's compiled-in catalogue happens in the AgentCollective webhook.
Role AgentRole `json:"role"`

// Replicas is the baseline replica count (before KEDA scaling).
Expand Down Expand Up @@ -214,7 +216,7 @@ type ScalingSpec struct {
// RoleScalingSpec configures KEDA scaling for a single agent role.
type RoleScalingSpec struct {
// Role identifies which agent role this scaling config applies to.
// +kubebuilder:validation:Enum=ingester;analyst;synthesizer;arbiter;observer
// Validation: regex on the AgentRole type + webhook catalogue check.
Role AgentRole `json:"role"`

// MinReplicas is the KEDA minimum replica count.
Expand Down
199 changes: 199 additions & 0 deletions operator/api/v1alpha1/agentcollective_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright 2026 ACC Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0

package v1alpha1

import (
"fmt"
"strings"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

"github.com/redhat-ai-dev/agentic-cell-corpus/operator/internal/rolecatalogue"
)

var agentcollectivelog = logf.Log.WithName("agentcollective-resource")

// SetupWebhookWithManager registers the webhook with the Manager.
func (r *AgentCollective) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}

// +kubebuilder:webhook:path=/mutate-acc-redhat-io-v1alpha1-agentcollective,mutating=true,failurePolicy=fail,sideEffects=None,groups=acc.redhat.io,resources=agentcollectives,verbs=create;update,versions=v1alpha1,name=magentcollective.kb.io,admissionReviewVersions=v1

var _ webhook.Defaulter = &AgentCollective{}

// Default implements webhook.Defaulter to set default values.
func (r *AgentCollective) Default() {
agentcollectivelog.Info("default", "name", r.Name)

if r.Spec.HeartbeatIntervalSeconds == 0 {
r.Spec.HeartbeatIntervalSeconds = 30
}
for i := range r.Spec.Agents {
if r.Spec.Agents[i].Replicas == 0 {
r.Spec.Agents[i].Replicas = 1
}
}
if r.Spec.LLM.EmbeddingModel == "" {
r.Spec.LLM.EmbeddingModel = "all-MiniLM-L6-v2"
}
}

// +kubebuilder:webhook:path=/validate-acc-redhat-io-v1alpha1-agentcollective,mutating=false,failurePolicy=fail,sideEffects=None,groups=acc.redhat.io,resources=agentcollectives,verbs=create;update,versions=v1alpha1,name=vagentcollective.kb.io,admissionReviewVersions=v1

var _ webhook.Validator = &AgentCollective{}

// ValidateCreate implements webhook.Validator.
func (r *AgentCollective) ValidateCreate() (admission.Warnings, error) {
agentcollectivelog.Info("validate create", "name", r.Name)
return nil, r.validateAgentCollective()
}

// ValidateUpdate implements webhook.Validator.
func (r *AgentCollective) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
agentcollectivelog.Info("validate update", "name", r.Name)
return nil, r.validateAgentCollective()
}

// ValidateDelete implements webhook.Validator.
func (r *AgentCollective) ValidateDelete() (admission.Warnings, error) {
return nil, nil
}

// validateAgentCollective enforces semantic rules that the CRD schema can't
// express: role names must be present in the operator's compiled-in
// catalogue (see internal/rolecatalogue), per-role replica counts in
// scaling overrides must reference declared agents, etc.
func (r *AgentCollective) validateAgentCollective() error {
var allErrs field.ErrorList

// Roles in spec.agents[*] must be in the catalogue.
declaredRoles := map[string]bool{}
for i, a := range r.Spec.Agents {
role := string(a.Role)
declaredRoles[role] = true
if !rolecatalogue.IsKnown(role) {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "agents").Index(i).Child("role"),
role,
unknownRoleMessage(role),
))
}
}

// Roles referenced in spec.scaling.roleScaling[*] must (a) be in the
// catalogue, AND (b) appear in spec.agents — otherwise the scaling
// override is dead config.
if r.Spec.Scaling != nil {
for i, rs := range r.Spec.Scaling.RoleScaling {
role := string(rs.Role)
if !rolecatalogue.IsKnown(role) {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "scaling", "roleScaling").Index(i).Child("role"),
role,
unknownRoleMessage(role),
))
continue
}
if !declaredRoles[role] {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "scaling", "roleScaling").Index(i).Child("role"),
role,
fmt.Sprintf("role %q is not declared in spec.agents — scaling override has no target", role),
))
}
if rs.MaxReplicas > 0 && rs.MinReplicas > rs.MaxReplicas {
allErrs = append(allErrs, field.Invalid(
field.NewPath("spec", "scaling", "roleScaling").Index(i).Child("minReplicas"),
rs.MinReplicas,
fmt.Sprintf("minReplicas (%d) must not exceed maxReplicas (%d)", rs.MinReplicas, rs.MaxReplicas),
))
}
}
}

// LLM backend wiring sanity — the schema enforces presence of the right
// sub-struct via OpenAPI, but we cross-check that the chosen backend's
// sub-struct is non-nil here so the controller never has to nil-guard.
switch r.Spec.LLM.Backend {
case LLMBackendOllama:
if r.Spec.LLM.Ollama == nil {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "llm", "ollama"),
"llm.ollama is required when llm.backend=ollama",
))
}
case LLMBackendAnthropic:
if r.Spec.LLM.Anthropic == nil {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "llm", "anthropic"),
"llm.anthropic is required when llm.backend=anthropic",
))
}
case LLMBackendVLLM:
if r.Spec.LLM.VLLM == nil {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "llm", "vllm"),
"llm.vllm is required when llm.backend=vllm",
))
}
case LLMBackendLlamaStack:
if r.Spec.LLM.LlamaStack == nil {
allErrs = append(allErrs, field.Required(
field.NewPath("spec", "llm", "llamaStack"),
"llm.llamaStack is required when llm.backend=llama_stack",
))
}
}

if len(allErrs) == 0 {
return nil
}
return apierrors.NewInvalid(
schema.GroupKind{Group: "acc.redhat.io", Kind: "AgentCollective"},
r.Name, allErrs,
)
}

// unknownRoleMessage formats the error for a role that doesn't appear in
// the catalogue, including up to three closest matches by Levenshtein
// distance. The intent is to make typos obvious without listing all 47
// known roles inline.
func unknownRoleMessage(role string) string {
suggestions := rolecatalogue.Suggest(role, 3)
if len(suggestions) == 0 {
return fmt.Sprintf(
"role %q is not in the operator's known-roles catalogue; "+
"add roles/%s/role.yaml to the source tree and rebuild the operator, "+
"or pick one of the existing personas",
role, role,
)
}
return fmt.Sprintf(
"role %q is not in the operator's known-roles catalogue; did you mean %s?",
role, strings.Join(quoteAll(suggestions), ", "),
)
}

func quoteAll(ss []string) []string {
out := make([]string, len(ss))
for i, s := range ss {
out[i] = fmt.Sprintf("%q", s)
}
return out
}
Loading