agent-orchestrator/backend/internal/daemon/tracker_intake_wiring.go

133 lines
4.1 KiB
Go

package daemon
import (
"context"
"errors"
"log/slog"
"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"
aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
)
// startTrackerIntake wires the opt-in GitHub issue-intake loop. The observer
// always runs — Poll re-reads each project's config on every tick and skips
// projects with intake disabled, so a project enabling intake after daemon
// boot is picked up on the next tick without a restart. The adapter itself
// stays lazy so daemon readiness is not blocked by credential probing or a gh
// CLI call, and no token is resolved until some enabled project is actually
// polled.
func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, logger *slog.Logger) <-chan struct{} {
resolver := trackerintake.SingleTrackerResolver{
Provider: domain.TrackerProviderGitHub,
Adapter: newLazyGitHubTracker(logger),
}
observer := trackerintake.New(resolver, store, sessions, trackerintake.Config{Logger: logger})
return observer.Start(ctx)
}
// ---------------------------------------------------------------------------
// GitHub lazy adapter (token sourced from env or gh CLI fallback)
// ---------------------------------------------------------------------------
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 != nil {
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 := aoprocess.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
}