Skip to content
Closed
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
27 changes: 19 additions & 8 deletions backend/internal/cli/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,29 @@ type roleOverride struct {
AgentConfig agentConfig `json:"agentConfig,omitempty"`
}

// trackerIntakeConfig mirrors domain.TrackerIntakeConfig.
type trackerIntakeConfig struct {
Enabled bool `json:"enabled,omitempty"`
Provider string `json:"provider,omitempty"`
Repo string `json:"repo,omitempty"`
Labels []string `json:"labels,omitempty"`
Assignee string `json:"assignee,omitempty"`
Limit int `json:"limit,omitempty"`
}

// projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI
// client. The CLI sets common fields via flags and the whole object via
// --config-json.
type projectConfig struct {
DefaultBranch string `json:"defaultBranch,omitempty"`
SessionPrefix string `json:"sessionPrefix,omitempty"`
Env map[string]string `json:"env,omitempty"`
Symlinks []string `json:"symlinks,omitempty"`
PostCreate []string `json:"postCreate,omitempty"`
AgentConfig agentConfig `json:"agentConfig,omitempty"`
Worker roleOverride `json:"worker,omitempty"`
Orchestrator roleOverride `json:"orchestrator,omitempty"`
DefaultBranch string `json:"defaultBranch,omitempty"`
SessionPrefix string `json:"sessionPrefix,omitempty"`
Env map[string]string `json:"env,omitempty"`
Symlinks []string `json:"symlinks,omitempty"`
PostCreate []string `json:"postCreate,omitempty"`
AgentConfig agentConfig `json:"agentConfig,omitempty"`
Worker roleOverride `json:"worker,omitempty"`
Orchestrator roleOverride `json:"orchestrator,omitempty"`
TrackerIntake trackerIntakeConfig `json:"trackerIntake,omitempty"`
}

// setConfigRequest mirrors the daemon's SetConfigInput body for
Expand Down
1 change: 1 addition & 0 deletions backend/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func Run() error {
if reconcileErr := sessMgr.Reconcile(ctx); reconcileErr != nil {
log.Error("reconcile sessions on boot failed", "err", reconcileErr)
}
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)

runErr := srv.Run(ctx)

Expand Down
10 changes: 7 additions & 3 deletions backend/internal/daemon/lifecycle_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ type lifecycleStack struct {
// LCM is the Lifecycle Manager (the canonical write path). It is exposed so
// startSession can share the same reducer the reaper drives, rather than
// standing up a second store+LCM pair that would diverge under writes.
LCM *lifecycle.Manager
reaperDone <-chan struct{}
scmDone <-chan struct{}
LCM *lifecycle.Manager
reaperDone <-chan struct{}
scmDone <-chan struct{}
trackerDone <-chan struct{}
}

// startLifecycle constructs the Lifecycle Manager over the store and starts the
Expand All @@ -56,6 +57,9 @@ func (l *lifecycleStack) Stop() {
if l.scmDone != nil {
<-l.scmDone
}
if l.trackerDone != nil {
<-l.trackerDone
}
}

// sessionLifecycle is the narrow surface of sessionmanager.Manager used for
Expand Down
120 changes: 120 additions & 0 deletions backend/internal/daemon/tracker_intake_wiring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package daemon

import (
"context"
"errors"
"log/slog"
"os/exec"
"strings"
"sync"
"time"

trackergithub "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/github"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
trackerintake "github.com/aoagents/agent-orchestrator/backend/internal/observe/trackerintake"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)

// startTrackerIntake wires the opt-in issue-intake loop. The tracker itself is
// lazy so daemon startup and projects without intake enabled do not pay an auth
// or network cost.
func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, logger *slog.Logger) <-chan struct{} {
observer := trackerintake.New(newLazyGitHubTracker(logger), store, sessions, trackerintake.Config{Logger: logger})
return observer.Start(ctx)
}

type lazyGitHubTracker struct {
logger *slog.Logger
tokens *trackerTokenSource
mu sync.Mutex
tracker ports.Tracker
}

func newLazyGitHubTracker(logger *slog.Logger) *lazyGitHubTracker {
return &lazyGitHubTracker{logger: logger, tokens: &trackerTokenSource{}}
}

func (t *lazyGitHubTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) {
tracker, err := t.resolve()
if err != nil {
return domain.Issue{}, err
}
return tracker.Get(ctx, id)
}

func (t *lazyGitHubTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) {
tracker, err := t.resolve()
if err != nil {
return nil, err
}
return tracker.List(ctx, repo, filter)
}

func (t *lazyGitHubTracker) Preflight(ctx context.Context) error {
tracker, err := t.resolve()
if err != nil {
return err
}
return tracker.Preflight(ctx)
}

func (t *lazyGitHubTracker) resolve() (ports.Tracker, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.tracker != nil {
return t.tracker, nil
}
tracker, err := trackergithub.New(trackergithub.Options{Token: t.tokens})
if err != nil {
if errors.Is(err, trackergithub.ErrNoToken) {
t.logger.Warn("tracker intake disabled: no usable GitHub token", "err", err)
}
return nil, err
}
t.tracker = tracker
return tracker, nil
}

const (
trackerTokenCacheTTL = 5 * time.Minute
trackerTokenCommandTimeout = 5 * time.Second
)

// trackerTokenSource mirrors the SCM credential precedence while returning the
// tracker adapter's own ErrNoToken sentinel.
type trackerTokenSource struct {
mu sync.Mutex
token string
expiresAt time.Time
}

func (s *trackerTokenSource) Token(ctx context.Context) (string, error) {
env := trackergithub.EnvTokenSource{EnvVars: []string{"AO_GITHUB_TOKEN"}}
if tok, err := env.Token(ctx); err == nil {
return tok, nil
} else if !errors.Is(err, trackergithub.ErrNoToken) {
return "", err
}

s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if s.token != "" && now.Before(s.expiresAt) {
return s.token, nil
}
cmdCtx, cancel := context.WithTimeout(ctx, trackerTokenCommandTimeout)
defer cancel()
out, err := exec.CommandContext(cmdCtx, "gh", "auth", "token").Output()
if err != nil {
return "", err
}
token := strings.TrimSpace(string(out))
if token == "" {
return "", trackergithub.ErrNoToken
}
s.token = token
s.expiresAt = now.Add(trackerTokenCacheTTL)
return token, nil
}
84 changes: 81 additions & 3 deletions backend/internal/domain/projectconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import (
//
// 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.
// 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"`
Expand All @@ -41,6 +40,32 @@ type ProjectConfig struct {
// 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"`

// 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"`
}

// TrackerIntakeConfig controls the first issue-intake slice for a project.
// Enabled requires at least one explicit eligibility rule so turning intake on
// cannot accidentally drain an entire issue backlog.
type TrackerIntakeConfig struct {
Enabled bool `json:"enabled,omitempty"`
// Provider defaults to github when Enabled is true.
Provider TrackerProvider `json:"provider,omitempty" enum:"github"`
// Repo is the provider-native repository key ("owner/repo" for GitHub). When
// empty, the intake loop derives it from the project's repo origin URL.
Repo string `json:"repo,omitempty"`
// Labels narrows eligible issues. All labels are forwarded to the provider's
// list filter; providers decide whether the match is all-of or provider-native.
Labels []string `json:"labels,omitempty"`
// Assignee narrows eligible issues to one assignee. Provider-specific values
// such as "*" are passed through unchanged.
Assignee string `json:"assignee,omitempty"`
// Limit caps the number of issues fetched per poll. Zero lets the adapter use
// its default.
Limit int `json:"limit,omitempty"`
}

// ReviewerConfig names one reviewer agent by harness. The harness is drawn from
Expand Down Expand Up @@ -92,6 +117,7 @@ func (c ProjectConfig) WithDefaults() ProjectConfig {
if c.DefaultBranch == "" {
c.DefaultBranch = def.DefaultBranch
}
c.TrackerIntake = c.TrackerIntake.WithDefaults()
return c
}

Expand Down Expand Up @@ -128,6 +154,58 @@ func (c ProjectConfig) Validate() error {
return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness)
}
}
if err := c.TrackerIntake.Validate(); err != nil {
return err
}
return nil
}

// WithDefaults fills the provider only when intake is enabled. Disabled intake
// leaves the zero value untouched so empty project configs still store as NULL.
func (c TrackerIntakeConfig) WithDefaults() TrackerIntakeConfig {
if c.Enabled && c.Provider == "" {
c.Provider = TrackerProviderGitHub
}
return c
}

// Validate rejects accidental broad intake and unknown providers.
func (c TrackerIntakeConfig) Validate() error {
if !c.Enabled {
return nil
}
c = c.WithDefaults()
if c.Provider != TrackerProviderGitHub {
return fmt.Errorf("trackerIntake.provider: unknown provider %q", c.Provider)
}
repo := strings.TrimSpace(c.Repo)
if repo != c.Repo {
return fmt.Errorf("trackerIntake.repo: must be provider-native without surrounding whitespace")
}
if repo != "" && strings.ContainsAny(repo, " \t\r\n") {
return fmt.Errorf("trackerIntake.repo: must be provider-native without whitespace")
}
hasLabel := false
for i, label := range c.Labels {
trimmed := strings.TrimSpace(label)
if trimmed == "" {
return fmt.Errorf("trackerIntake.labels[%d]: must not be empty", i)
}
if trimmed != label {
return fmt.Errorf("trackerIntake.labels[%d]: must not contain surrounding whitespace", i)
}
hasLabel = true
}
assignee := strings.TrimSpace(c.Assignee)
if assignee != c.Assignee {
return fmt.Errorf("trackerIntake.assignee: must not contain surrounding whitespace")
}
if !hasLabel && assignee == "" {
return fmt.Errorf("trackerIntake: enabled intake requires at least one label or assignee rule")
}
if c.Limit < 0 {
return fmt.Errorf("trackerIntake.limit: must be non-negative")
}
return nil
}

Expand Down
19 changes: 19 additions & 0 deletions backend/internal/domain/projectconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ func TestProjectConfigValidate(t *testing.T) {
{"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true},
{"worker harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessCodex)}}}, true},
{"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true},
{"tracker intake label rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, false},
{"tracker intake assignee rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, false},
{"tracker intake no rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}, true},
{"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "unknown", Labels: []string{"agent-ready"}}}, true},
{"tracker intake empty label", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{""}}}, true},
{"tracker intake label with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{" agent-ready"}}}, true},
{"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo", Labels: []string{"agent-ready"}}}, true},
{"tracker intake assignee with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: " alice"}}, true},
{"tracker intake negative limit", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}, Limit: -1}}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -71,6 +80,16 @@ func TestProjectConfigWithDefaults(t *testing.T) {
if got.AgentConfig.Model != "m" {
t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig)
}

got = (ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}).WithDefaults()
if got.TrackerIntake.Provider != TrackerProviderGitHub {
t.Fatalf("TrackerIntake.Provider = %q, want %q", got.TrackerIntake.Provider, TrackerProviderGitHub)
}

got = (ProjectConfig{}).WithDefaults()
if got.TrackerIntake.Provider != "" {
t.Fatalf("disabled TrackerIntake.Provider = %q, want empty", got.TrackerIntake.Provider)
}
}

func TestResolveReviewerHarness(t *testing.T) {
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,8 @@ components:
items:
type: string
type: array
trackerIntake:
$ref: '#/components/schemas/TrackerIntakeConfig'
worker:
$ref: '#/components/schemas/RoleOverride'
type: object
Expand Down Expand Up @@ -2403,6 +2405,25 @@ components:
- body
- githubReviewId
type: object
TrackerIntakeConfig:
properties:
assignee:
type: string
enabled:
type: boolean
labels:
items:
type: string
type: array
limit:
type: integer
provider:
enum:
- github
type: string
repo:
type: string
type: object
WorkspaceRepo:
properties:
name:
Expand Down
15 changes: 8 additions & 7 deletions backend/internal/httpd/apispec/specgen/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,14 @@ var schemaNames = map[string]string{
// httpd/envelope
"EnvelopeAPIError": "APIError",
// domain
"DomainProjectID": "ProjectID",
"DomainSessionID": "SessionID",
"DomainIssueID": "IssueID",
"DomainSession": "Session",
"DomainProjectConfig": "ProjectConfig",
"DomainAgentConfig": "AgentConfig",
"DomainRoleOverride": "RoleOverride",
"DomainProjectID": "ProjectID",
"DomainSessionID": "SessionID",
"DomainIssueID": "IssueID",
"DomainSession": "Session",
"DomainProjectConfig": "ProjectConfig",
"DomainTrackerIntakeConfig": "TrackerIntakeConfig",
"DomainAgentConfig": "AgentConfig",
"DomainRoleOverride": "RoleOverride",
// httpd/controllers (wire envelopes)
"ControllersListProjectsResponse": "ListProjectsResponse",
"ControllersProjectResponse": "ProjectResponse",
Expand Down
Loading