182 lines
6.6 KiB
Go
182 lines
6.6 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. TrackerIntake feeds the background
|
|
// issue-intake loop.
|
|
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 claude-code (see ResolveReviewerHarness).
|
|
Reviewers []ReviewerConfig `json:"reviewers,omitempty"`
|
|
|
|
// TrackerIntake controls issue-driven worker spawning. It is opt-in and
|
|
// read-only toward the tracker in v1: matching issues spawn sessions, but the
|
|
// tracker is not commented on or transitioned.
|
|
TrackerIntake TrackerIntakeConfig `json:"trackerIntake,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 claude-code is used.
|
|
func (c ProjectConfig) ResolveReviewerHarness(_ AgentHarness) ReviewerHarness {
|
|
if len(c.Reviewers) > 0 {
|
|
return c.Reviewers[0].Harness
|
|
}
|
|
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
|
|
}
|
|
c.TrackerIntake = c.TrackerIntake.WithDefaults()
|
|
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)
|
|
}
|
|
}
|
|
if err := c.TrackerIntake.Validate(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateNoWhitespaceField(name, value string) error {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(value) != value {
|
|
return fmt.Errorf("%s: must not have leading or trailing whitespace", name)
|
|
}
|
|
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
|
|
}
|