agent-orchestrator/backend/internal/domain/projectconfig.go

168 lines
6.3 KiB
Go

package domain
import (
"fmt"
"path/filepath"
"reflect"
"strings"
)
// ProjectConfig is the typed per-project configuration — the SQLite twin of the
// legacy agent-orchestrator.yaml `projects.<id>` block. It is persisted as one
// JSON blob per project and resolved at spawn. Each field is typed and
// validated; there is no free-form map.
//
// Only fields with a live consumer are modeled: DefaultBranch, Env, Symlinks,
// PostCreate, AgentConfig, and the role overrides are consumed at spawn;
// SessionPrefix feeds the display prefix. Settings whose consumers do not yet
// exist (tracker/SCM per-project config, prompt rules) are intentionally absent
// and land in focused follow-up PRs alongside the code that reads them.
type ProjectConfig struct {
// DefaultBranch is the base branch new session worktrees are created from.
DefaultBranch string `json:"defaultBranch,omitempty"`
// SessionPrefix overrides the displayed session-id prefix.
SessionPrefix string `json:"sessionPrefix,omitempty"`
// Env are extra environment variables forwarded into worker session
// runtimes. AO-internal vars (AO_SESSION, AO_PROJECT_ID, …) always win.
Env map[string]string `json:"env,omitempty"`
// Symlinks are repo-relative paths symlinked into each session workspace.
Symlinks []string `json:"symlinks,omitempty"`
// PostCreate are shell commands run in the workspace after it is created.
PostCreate []string `json:"postCreate,omitempty"`
// AgentConfig is the default agent config for the project.
AgentConfig AgentConfig `json:"agentConfig,omitempty"`
// Worker and Orchestrator are role-specific harness/agent-config overrides.
Worker RoleOverride `json:"worker,omitempty"`
Orchestrator RoleOverride `json:"orchestrator,omitempty"`
// Reviewers names the agent(s) that review a worker's PR when a review is
// triggered. It is configured independently of the Worker override; an empty
// list falls back to the worker's own harness (see ResolveReviewerHarness).
Reviewers []ReviewerConfig `json:"reviewers,omitempty"`
}
// ReviewerConfig names one reviewer agent by harness. The harness is drawn from
// the reviewer vocabulary (ReviewerHarness), which is distinct from the worker
// AgentHarness set.
type ReviewerConfig struct {
Harness ReviewerHarness `json:"harness"`
}
// FallbackReviewerHarness is the reviewer used when a project configures none
// and the worker's harness is not itself a supported reviewer.
const FallbackReviewerHarness = ReviewerClaudeCode
// ResolveReviewerHarness picks the reviewer harness for a worker. A configured
// reviewer wins; otherwise it reuses the worker's own harness when that harness
// is also a supported reviewer, falling back to claude-code.
func (c ProjectConfig) ResolveReviewerHarness(workerHarness AgentHarness) ReviewerHarness {
if len(c.Reviewers) > 0 {
return c.Reviewers[0].Harness
}
if h := ReviewerHarness(workerHarness); h.IsKnown() {
return h
}
return FallbackReviewerHarness
}
// RoleOverride overrides the harness and/or agent config for a session role.
type RoleOverride struct {
Harness AgentHarness `json:"agent,omitempty"`
AgentConfig AgentConfig `json:"agentConfig,omitempty"`
}
// DefaultBranchName is the base branch used when a project configures none.
const DefaultBranchName = "main"
// DefaultProjectConfig returns the config a project has when it sets nothing:
// branch "main". Every other field defaults to its zero value (no
// env/symlinks/post-create, agent + role defaults).
func DefaultProjectConfig() ProjectConfig {
return ProjectConfig{
DefaultBranch: DefaultBranchName,
}
}
// WithDefaults overlays DefaultProjectConfig onto c, filling only fields the
// project left unset. A set field is always preserved.
func (c ProjectConfig) WithDefaults() ProjectConfig {
def := DefaultProjectConfig()
if c.DefaultBranch == "" {
c.DefaultBranch = def.DefaultBranch
}
return c
}
// IsZero reports whether the config carries no settings, so storage can persist
// SQL NULL and resolution can skip an empty config.
func (c ProjectConfig) IsZero() bool {
return reflect.DeepEqual(c, ProjectConfig{})
}
// Validate rejects values outside the typed vocabulary so a bad config is
// refused when it is set (CLI/API) rather than surfacing at spawn.
func (c ProjectConfig) Validate() error {
if err := c.AgentConfig.Validate(); err != nil {
return err
}
if err := validateNameComponent("sessionPrefix", c.SessionPrefix); err != nil {
return err
}
for role, ro := range map[string]RoleOverride{"worker": c.Worker, "orchestrator": c.Orchestrator} {
if ro.Harness != "" && !ro.Harness.IsKnown() {
return fmt.Errorf("%s.agent: unknown harness %q", role, ro.Harness)
}
if err := ro.AgentConfig.Validate(); err != nil {
return fmt.Errorf("%s.%w", role, err)
}
}
for _, s := range c.Symlinks {
if err := validateRepoRelative(s); err != nil {
return fmt.Errorf("symlink %q: %w", s, err)
}
}
for i, rv := range c.Reviewers {
if !rv.Harness.IsKnown() {
return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness)
}
}
return nil
}
func validateNameComponent(name, value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
if strings.ContainsAny(trimmed, `/\`) || trimmed == "." || trimmed == ".." {
return fmt.Errorf("%s: must not contain path separators or traversal components", name)
}
return nil
}
// validateRepoRelative refuses paths that would let a project config escape
// its repo root: absolute paths and any ".." segment (before or after Clean).
// The same guard runs at spawn time as defense-in-depth, but enforcing it here
// rejects bad config when it is set rather than at every later spawn.
func validateRepoRelative(p string) error {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
return nil
}
if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, `\`) {
return fmt.Errorf("path must be repo-relative and must not escape the project root")
}
clean := filepath.Clean(trimmed)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return fmt.Errorf("path must be repo-relative and must not escape the project root")
}
for _, seg := range strings.Split(filepath.ToSlash(clean), "/") {
if seg == ".." {
return fmt.Errorf("path must be repo-relative and must not escape the project root")
}
}
return nil
}