From 9dea1f684be042fec38c72f31690f990e7be5b15 Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Sat, 27 Jun 2026 15:57:41 +0530 Subject: [PATCH 1/3] feat(tracker): spawn sessions from eligible issues --- backend/internal/cli/project.go | 27 +- backend/internal/daemon/daemon.go | 1 + backend/internal/daemon/lifecycle_wiring.go | 10 +- .../internal/daemon/tracker_intake_wiring.go | 120 ++++++ backend/internal/domain/projectconfig.go | 84 ++++- backend/internal/domain/projectconfig_test.go | 19 + backend/internal/httpd/apispec/openapi.yaml | 21 ++ .../internal/httpd/apispec/specgen/build.go | 15 +- .../observe/trackerintake/observer.go | 340 +++++++++++++++++ .../observe/trackerintake/observer_test.go | 341 ++++++++++++++++++ .../storage/sqlite/store/store_test.go | 1 + frontend/forge.config.ts | 4 +- frontend/package.json | 1 + frontend/src/api/schema.ts | 10 + 14 files changed, 971 insertions(+), 23 deletions(-) create mode 100644 backend/internal/daemon/tracker_intake_wiring.go create mode 100644 backend/internal/observe/trackerintake/observer.go create mode 100644 backend/internal/observe/trackerintake/observer_test.go diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index ff74dec8..e9125c70 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -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 diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 3603fbe9..b6c1a253 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -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) diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index 676dcb8e..c0489b8c 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -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 @@ -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 diff --git a/backend/internal/daemon/tracker_intake_wiring.go b/backend/internal/daemon/tracker_intake_wiring.go new file mode 100644 index 00000000..c05575e3 --- /dev/null +++ b/backend/internal/daemon/tracker_intake_wiring.go @@ -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 +} diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 840ad34b..744a01ee 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -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"` @@ -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 @@ -92,6 +117,7 @@ func (c ProjectConfig) WithDefaults() ProjectConfig { if c.DefaultBranch == "" { c.DefaultBranch = def.DefaultBranch } + c.TrackerIntake = c.TrackerIntake.WithDefaults() return c } @@ -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 } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 58d9c3ba..c78e16a3 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -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) { @@ -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) { diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a66191e5..343ed741 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1828,6 +1828,8 @@ components: items: type: string type: array + trackerIntake: + $ref: '#/components/schemas/TrackerIntakeConfig' worker: $ref: '#/components/schemas/RoleOverride' type: object @@ -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: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index a2611d61..76d1d88c 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -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", diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go new file mode 100644 index 00000000..6059223d --- /dev/null +++ b/backend/internal/observe/trackerintake/observer.go @@ -0,0 +1,340 @@ +// Package trackerintake implements the opt-in issue-intake observer. It polls a +// project's configured tracker for eligible issues and starts one worker session +// per issue, leaving PR/lifecycle handling to the existing observers. +package trackerintake + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/observe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // DefaultTickInterval is intentionally slower than runtime liveness checks: + // intake is a backlog sweep, not an interactive status surface. + DefaultTickInterval = time.Minute + // DefaultFailureBackoff suppresses repeated polls for a project after an + // intake failure. The observer retries automatically after this window. + DefaultFailureBackoff = 5 * time.Minute + // maxIntakePromptLen mirrors the session HTTP prompt limit. Intake uses the + // session service directly, so it must enforce the same boundary itself. + maxIntakePromptLen = 4096 + + intakePromptTruncationNotice = "\n\n[Issue content truncated to fit the session prompt limit. Open the linked issue for the full details.]\n" + intakePromptFooter = "\nImplement the requested change in this repository, run the relevant checks, and open or update a pull request when ready." +) + +// Store is the durable read surface the observer needs. +type Store interface { + ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) +} + +// Spawner is the session creation surface used by intake. +type Spawner interface { + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) +} + +// Config holds optional observer knobs. Zero values use production defaults. +type Config struct { + Tick time.Duration + FailureBackoff time.Duration + Clock func() time.Time + Logger *slog.Logger +} + +// Observer polls configured projects and starts sessions for eligible issues. +type Observer struct { + tracker ports.Tracker + store Store + spawner Spawner + tick time.Duration + failureBackoff time.Duration + clock func() time.Time + logger *slog.Logger + backoffUntil map[string]time.Time +} + +// New constructs an Observer with safe defaults. +func New(tracker ports.Tracker, store Store, spawner Spawner, cfg Config) *Observer { + o := &Observer{tracker: tracker, store: store, spawner: spawner, tick: cfg.Tick, failureBackoff: cfg.FailureBackoff, clock: cfg.Clock, logger: cfg.Logger, backoffUntil: map[string]time.Time{}} + if o.tick <= 0 { + o.tick = DefaultTickInterval + } + if o.failureBackoff <= 0 { + o.failureBackoff = DefaultFailureBackoff + } + if o.clock == nil { + o.clock = time.Now + } + if o.logger == nil { + o.logger = slog.Default() + } + return o +} + +// Start launches the observer loop. The first poll runs immediately inside the +// goroutine, keeping daemon startup non-blocking. +func (o *Observer) Start(ctx context.Context) <-chan struct{} { + return observe.StartPollLoop(ctx, o.tick, o.Poll, o.logger, "tracker intake") +} + +// Poll runs one synchronous intake pass. Store discovery failures are returned +// because they prevent the pass from knowing the current world; provider and +// spawn failures are logged and skipped so one bad issue/project does not block +// the rest of the daemon. +func (o *Observer) Poll(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if o.tracker == nil || o.store == nil || o.spawner == nil { + return nil + } + now := o.clock().UTC() + projects, err := o.store.ListProjects(ctx) + if err != nil { + return err + } + sessions, err := o.store.ListAllSessions(ctx) + if err != nil { + return err + } + seen := seenIssueIDs(sessions) + for _, project := range projects { + if err := ctx.Err(); err != nil { + return err + } + if until, ok := o.backoffUntil[project.ID]; ok && now.Before(until) { + o.logger.Debug("tracker intake: project in failure backoff", "project", project.ID, "until", until) + continue + } + if failed := o.pollProject(ctx, project, seen); failed { + o.backoffUntil[project.ID] = now.Add(o.failureBackoff) + } else { + delete(o.backoffUntil, project.ID) + } + } + return nil +} + +// pollProject returns failed=true for conditions that should be retried after a +// backoff window rather than logged on every poll. +func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord, seen map[domain.IssueID]bool) (failed bool) { + cfg := project.Config.TrackerIntake.WithDefaults() + if !cfg.Enabled { + return false + } + if err := cfg.Validate(); err != nil { + o.logger.Warn("tracker intake: skipping project with invalid config", "project", project.ID, "err", err) + return true + } + repo, ok := trackerRepo(project, cfg) + if !ok { + o.logger.Warn("tracker intake: skipping project without tracker repo", "project", project.ID, "origin", project.RepoOriginURL) + return true + } + issues, err := o.tracker.List(ctx, repo, domain.ListFilter{ + State: domain.ListOpen, + Labels: cfg.Labels, + Assignee: cfg.Assignee, + Limit: cfg.Limit, + }) + if err != nil { + o.logger.Error("tracker intake: list issues failed", "project", project.ID, "repo", repo.Native, "err", err) + return true + } + var spawnFailed bool + for _, issue := range issues { + if ctx.Err() != nil { + return true + } + if issue.State != domain.IssueOpen { + continue + } + if !issueMatchesConfig(issue, cfg) { + continue + } + issueID := CanonicalIssueID(issue.ID) + if issueID == "" || seen[issueID] || seen[domain.IssueID(issue.ID.Native)] { + continue + } + if _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{ + ProjectID: projectID(project), + IssueID: issueID, + Kind: domain.KindWorker, + Prompt: BuildIssuePrompt(issue), + }); err != nil { + o.logger.Error("tracker intake: spawn issue session failed", "project", project.ID, "issue", issueID, "err", err) + spawnFailed = true + continue + } + seen[issueID] = true + if issue.ID.Native != "" { + seen[domain.IssueID(issue.ID.Native)] = true + } + } + return spawnFailed +} + +func issueMatchesConfig(issue domain.Issue, cfg domain.TrackerIntakeConfig) bool { + for _, required := range cfg.Labels { + if !containsFold(issue.Labels, strings.TrimSpace(required)) { + return false + } + } + assignee := strings.TrimSpace(cfg.Assignee) + switch { + case assignee == "": + return true + case assignee == "*": + return len(issue.Assignees) > 0 + case strings.EqualFold(assignee, "none"): + return len(issue.Assignees) == 0 + default: + return containsFold(issue.Assignees, assignee) + } +} + +func containsFold(values []string, needle string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), needle) { + return true + } + } + return false +} + +func seenIssueIDs(sessions []domain.SessionRecord) map[domain.IssueID]bool { + seen := make(map[domain.IssueID]bool, len(sessions)) + for _, sess := range sessions { + if sess.IssueID != "" { + seen[sess.IssueID] = true + } + } + return seen +} + +func projectID(project domain.ProjectRecord) domain.ProjectID { + return domain.ProjectID(project.ID) +} + +// CanonicalIssueID stores tracker issue ids in sessions.issue_id with the +// provider included, so future providers cannot collide on native ids. +func CanonicalIssueID(id domain.TrackerID) domain.IssueID { + provider := id.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + native := strings.TrimSpace(id.Native) + if native == "" { + return "" + } + return domain.IssueID(string(provider) + ":" + native) +} + +// BuildIssuePrompt turns normalized issue facts into the worker's initial task. +func BuildIssuePrompt(issue domain.Issue) string { + var b strings.Builder + fmt.Fprintf(&b, "Work on tracker issue %s.\n\n", CanonicalIssueID(issue.ID)) + if issue.Title != "" { + fmt.Fprintf(&b, "Title: %s\n", issue.Title) + } + if issue.URL != "" { + fmt.Fprintf(&b, "URL: %s\n", issue.URL) + } + if len(issue.Labels) > 0 { + fmt.Fprintf(&b, "Labels: %s\n", strings.Join(issue.Labels, ", ")) + } + if len(issue.Assignees) > 0 { + fmt.Fprintf(&b, "Assignees: %s\n", strings.Join(issue.Assignees, ", ")) + } + body := strings.TrimSpace(issue.Body) + if body != "" { + fmt.Fprintf(&b, "\nBody:\n%s\n", body) + } + b.WriteString(intakePromptFooter) + return capIntakePrompt(b.String()) +} + +func capIntakePrompt(prompt string) string { + if len(prompt) <= maxIntakePromptLen { + return prompt + } + prefix := strings.TrimSuffix(prompt, intakePromptFooter) + prefixBudget := maxIntakePromptLen - len(intakePromptTruncationNotice) - len(intakePromptFooter) + if prefixBudget <= 0 { + return truncateUTF8(prompt, maxIntakePromptLen) + } + return truncateUTF8(prefix, prefixBudget) + intakePromptTruncationNotice + intakePromptFooter +} + +func truncateUTF8(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := 0 + for i := range s { + if i > maxBytes { + break + } + cut = i + } + return s[:cut] +} + +func trackerRepo(project domain.ProjectRecord, cfg domain.TrackerIntakeConfig) (domain.TrackerRepo, bool) { + provider := cfg.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + native := strings.TrimSpace(cfg.Repo) + if native == "" { + native = parseGitHubRepoNative(project.RepoOriginURL) + } + if provider != domain.TrackerProviderGitHub || native == "" { + return domain.TrackerRepo{}, false + } + return domain.TrackerRepo{Provider: provider, Native: native}, true +} + +func parseGitHubRepoNative(remote string) string { + remote = strings.TrimSpace(remote) + if remote == "" { + return "" + } + if strings.HasPrefix(remote, "git@") { + if _, rest, ok := strings.Cut(remote, ":"); ok { + return cleanRepoPath(rest) + } + } + if u, err := url.Parse(remote); err == nil && u.Host != "" { + host := strings.TrimPrefix(strings.ToLower(u.Host), "www.") + if host == "github.com" || strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io") { + return cleanRepoPath(u.Path) + } + return "" + } + return cleanRepoPath(remote) +} + +func cleanRepoPath(path string) string { + path = strings.Trim(strings.TrimSpace(path), "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "" + } + owner := strings.TrimSpace(parts[len(parts)-2]) + repo := strings.TrimSpace(parts[len(parts)-1]) + if owner == "" || repo == "" { + return "" + } + return owner + "/" + repo +} diff --git a/backend/internal/observe/trackerintake/observer_test.go b/backend/internal/observe/trackerintake/observer_test.go new file mode 100644 index 00000000..4a731bda --- /dev/null +++ b/backend/internal/observe/trackerintake/observer_test.go @@ -0,0 +1,341 @@ +package trackerintake + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestPollSpawnsWorkerForEligibleIssue(t *testing.T) { + ctx := context.Background() + store := &fakeStore{ + projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ + Enabled: true, + Labels: []string{"agent-ready"}, + Limit: 10, + }}, + }}, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#12"}, + Title: "Fix login", + Body: "The login form submits twice.", + State: domain.IssueOpen, + URL: "https://github.com/acme/demo/issues/12", + Labels: []string{"agent-ready"}, + Assignees: []string{"alice"}, + }}} + spawner := &fakeSpawner{} + + if err := New(tracker, store, spawner, Config{Logger: discardLogger()}).Poll(ctx); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 { + t.Fatalf("spawn calls = %d, want 1", len(spawner.calls)) + } + call := spawner.calls[0] + if call.ProjectID != "demo" || call.Kind != domain.KindWorker { + t.Fatalf("spawn config = %+v", call) + } + if call.IssueID != "github:acme/demo#12" { + t.Fatalf("IssueID = %q, want canonical github id", call.IssueID) + } + if !strings.Contains(call.Prompt, "Fix login") || !strings.Contains(call.Prompt, "The login form submits twice.") { + t.Fatalf("prompt missing issue context:\n%s", call.Prompt) + } + if len(tracker.filters) != 1 { + t.Fatalf("tracker filters = %d, want 1", len(tracker.filters)) + } + if got := tracker.filters[0]; got.State != domain.ListOpen || got.Labels[0] != "agent-ready" || got.Limit != 10 { + t.Fatalf("tracker filter = %+v", got) + } +} + +func TestPollSkipsExistingIssueSessionsAfterRestart(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }}, + sessions: []domain.SessionRecord{{ID: "demo-1", ProjectID: "demo", IssueID: "github:acme/demo#12"}}, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#12"}, + Title: "Already running", + State: domain.IssueOpen, + }}} + spawner := &fakeSpawner{} + + if err := New(tracker, store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 0 { + t.Fatalf("spawn calls = %d, want 0", len(spawner.calls)) + } +} + +func TestPollSkipsIneligibleAndInvalidProjects(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{ + {ID: "off", RepoOriginURL: "https://github.com/acme/off.git"}, + {ID: "broad", RepoOriginURL: "https://github.com/acme/broad.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}}, + {ID: "missing-origin", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}}, + }, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/off#1"}, + Title: "ignored", + State: domain.IssueOpen, + }}} + spawner := &fakeSpawner{} + + if err := New(tracker, store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(tracker.repos) != 0 { + t.Fatalf("tracker was called for invalid/off projects: %+v", tracker.repos) + } + if len(spawner.calls) != 0 { + t.Fatalf("spawn calls = %d, want 0", len(spawner.calls)) + } +} + +func TestPollContinuesAfterTrackerAndSpawnFailures(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{ + { + ID: "bad", + RepoOriginURL: "https://github.com/acme/bad.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }, + { + ID: "good", + RepoOriginURL: "https://github.com/acme/good.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }, + }} + tracker := &fakeTracker{ + failRepos: map[string]error{"acme/bad": errors.New("rate limited")}, + issuesByRepo: map[string][]domain.Issue{ + "acme/good": { + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#1"}, Title: "first", State: domain.IssueOpen, Labels: []string{"agent-ready"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#2"}, Title: "second", State: domain.IssueOpen, Labels: []string{"agent-ready"}}, + }, + }, + } + spawner := &fakeSpawner{failIssue: domain.IssueID("github:acme/good#1")} + + if err := New(tracker, store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 2 { + t.Fatalf("spawn attempts = %d, want 2", len(spawner.calls)) + } + if spawner.calls[1].IssueID != "github:acme/good#2" { + t.Fatalf("second spawn issue = %q", spawner.calls[1].IssueID) + } +} + +func TestPollBacksOffProjectAfterFailure(t *testing.T) { + now := time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC) + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }}} + tracker := &fakeTracker{failRepos: map[string]error{"acme/demo": errors.New("rate limited")}} + observer := New(tracker, store, &fakeSpawner{}, Config{ + Clock: func() time.Time { return now }, + FailureBackoff: time.Minute, + Logger: discardLogger(), + }) + + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("first Poll() error = %v", err) + } + if len(tracker.repos) != 1 { + t.Fatalf("tracker calls after first poll = %d, want 1", len(tracker.repos)) + } + + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("second Poll() error = %v", err) + } + if len(tracker.repos) != 1 { + t.Fatalf("tracker calls during backoff = %d, want still 1", len(tracker.repos)) + } + + now = now.Add(time.Minute + time.Nanosecond) + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("third Poll() error = %v", err) + } + if len(tracker.repos) != 2 { + t.Fatalf("tracker calls after backoff = %d, want 2", len(tracker.repos)) + } +} + +func TestPollSkipsNonOpenIssueStates(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}}}, + }}} + tracker := &fakeTracker{issues: []domain.Issue{ + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "already active", State: domain.IssueInProgress, Labels: []string{"agent-ready"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "ready", State: domain.IssueOpen, Labels: []string{"agent-ready"}}, + }} + spawner := &fakeSpawner{} + + if err := New(tracker, store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 || spawner.calls[0].IssueID != "github:acme/demo#2" { + t.Fatalf("spawn calls = %+v, want only open issue #2", spawner.calls) + } +} + +func TestPollAppliesLocalEligibilityFilter(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Labels: []string{"agent-ready"}, Assignee: "alice"}}, + }}} + tracker := &fakeTracker{issues: []domain.Issue{ + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "missing label", State: domain.IssueOpen, Assignees: []string{"alice"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "wrong assignee", State: domain.IssueOpen, Labels: []string{"agent-ready"}, Assignees: []string{"bob"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#3"}, Title: "eligible", State: domain.IssueOpen, Labels: []string{"Agent-Ready"}, Assignees: []string{"Alice"}}, + }} + spawner := &fakeSpawner{} + + if err := New(tracker, store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 || spawner.calls[0].IssueID != "github:acme/demo#3" { + t.Fatalf("spawn calls = %+v, want only eligible issue #3", spawner.calls) + } +} + +func TestIssueMatchesConfigAssigneeSpecialValues(t *testing.T) { + assigned := domain.Issue{Assignees: []string{"alice"}} + unassigned := domain.Issue{} + if !issueMatchesConfig(assigned, domain.TrackerIntakeConfig{Assignee: "*"}) { + t.Fatal("assigned issue should match assignee=*") + } + if issueMatchesConfig(unassigned, domain.TrackerIntakeConfig{Assignee: "*"}) { + t.Fatal("unassigned issue should not match assignee=*") + } + if !issueMatchesConfig(unassigned, domain.TrackerIntakeConfig{Assignee: "none"}) { + t.Fatal("unassigned issue should match assignee=none") + } + if issueMatchesConfig(assigned, domain.TrackerIntakeConfig{Assignee: "none"}) { + t.Fatal("assigned issue should not match assignee=none") + } +} + +func TestBuildIssuePromptCapsLargeIssueBody(t *testing.T) { + prompt := BuildIssuePrompt(domain.Issue{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#99"}, + Title: "Large issue", + URL: "https://github.com/acme/demo/issues/99", + Body: strings.Repeat("body ", 2000), + }) + if len(prompt) > maxIntakePromptLen { + t.Fatalf("prompt length = %d, want <= %d", len(prompt), maxIntakePromptLen) + } + if !strings.Contains(prompt, "Issue content truncated") { + t.Fatalf("prompt missing truncation notice:\n%s", prompt) + } + if !strings.Contains(prompt, "https://github.com/acme/demo/issues/99") { + t.Fatalf("prompt missing issue URL:\n%s", prompt) + } + if !strings.HasSuffix(prompt, intakePromptFooter) { + t.Fatalf("prompt missing footer:\n%s", prompt) + } +} + +func TestTrackerRepoUsesConfiguredRepo(t *testing.T) { + project := domain.ProjectRecord{ + ID: "demo", + RepoOriginURL: "https://github.com/wrong/repo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ + Enabled: true, + Repo: "acme/demo", + Labels: []string{"agent-ready"}, + }}, + } + repo, ok := trackerRepo(project, project.Config.TrackerIntake.WithDefaults()) + if !ok { + t.Fatal("trackerRepo ok = false") + } + if repo.Native != "acme/demo" { + t.Fatalf("repo.Native = %q, want acme/demo", repo.Native) + } +} + +type fakeStore struct { + projects []domain.ProjectRecord + sessions []domain.SessionRecord +} + +func (f *fakeStore) ListProjects(context.Context) ([]domain.ProjectRecord, error) { + return append([]domain.ProjectRecord(nil), f.projects...), nil +} + +func (f *fakeStore) ListAllSessions(context.Context) ([]domain.SessionRecord, error) { + return append([]domain.SessionRecord(nil), f.sessions...), nil +} + +type fakeTracker struct { + issues []domain.Issue + issuesByRepo map[string][]domain.Issue + failRepos map[string]error + repos []domain.TrackerRepo + filters []domain.ListFilter +} + +func (f *fakeTracker) Get(context.Context, domain.TrackerID) (domain.Issue, error) { + return domain.Issue{}, nil +} + +func (f *fakeTracker) List(_ context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + f.repos = append(f.repos, repo) + f.filters = append(f.filters, filter) + if err := f.failRepos[repo.Native]; err != nil { + return nil, err + } + if f.issuesByRepo != nil { + return append([]domain.Issue(nil), f.issuesByRepo[repo.Native]...), nil + } + return append([]domain.Issue(nil), f.issues...), nil +} + +func (f *fakeTracker) Preflight(context.Context) error { return nil } + +type fakeSpawner struct { + calls []ports.SpawnConfig + failIssue domain.IssueID +} + +func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + f.calls = append(f.calls, cfg) + if cfg.IssueID == f.failIssue { + return domain.Session{}, errors.New("spawn failed") + } + return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, nil +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(testDiscard{}, nil)) +} + +type testDiscard struct{} + +func (testDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index c6a56477..43b798fd 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -86,6 +86,7 @@ func TestProjectConfigRoundTrips(t *testing.T) { PostCreate: []string{"echo hi"}, AgentConfig: domain.AgentConfig{Model: "claude-opus-4-5", Permissions: domain.PermissionModeAcceptEdits}, Worker: domain.RoleOverride{Harness: domain.HarnessCodex}, + TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Provider: domain.TrackerProviderGitHub, Repo: "acme/cfg", Labels: []string{"agent-ready"}, Assignee: "alice", Limit: 10}, } if err := s.UpsertProject(ctx, domain.ProjectRecord{ ID: "cfg", Path: "/tmp/cfg", RegisteredAt: now, Config: cfg, diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index 99c3ab70..14480602 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -1,5 +1,6 @@ import type { ForgeConfig } from "@electron-forge/shared-types"; import { VitePlugin } from "@electron-forge/plugin-vite"; +import type { NotaryToolCredentials } from "@electron/notarize/lib/types"; import MakerNSIS from "./makers/maker-nsis"; const config: ForgeConfig = { @@ -28,9 +29,8 @@ const config: ForgeConfig = { : undefined, osxNotarize: process.env.AO_NOTARY_PROFILE ? ({ - tool: "notarytool", keychainProfile: process.env.AO_NOTARY_PROFILE, - } as unknown as ForgeConfig["packagerConfig"]["osxNotarize"]) + } satisfies NotaryToolCredentials) : process.env.APPLE_ID ? { appleId: process.env.APPLE_ID, diff --git a/frontend/package.json b/frontend/package.json index 12429528..e9ad7a91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ }, "scripts": { "build:daemon": "node ./scripts/build-daemon.mjs", + "build": "npm run build:daemon && electron-forge package", "dev": "electron-forge start", "dev:web": "VITE_NO_ELECTRON=1 vite --config vite.renderer.config.ts", "prepackage": "npm run build:daemon", diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index a7f68302..75623ebb 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -639,6 +639,7 @@ export interface components { reviewers?: components["schemas"]["DomainReviewerConfig"][]; sessionPrefix?: string; symlinks?: string[]; + trackerIntake?: components["schemas"]["TrackerIntakeConfig"]; worker?: components["schemas"]["RoleOverride"]; }; ProjectGetResponse: { @@ -854,6 +855,15 @@ export interface components { /** @description Review verdict: approved or changes_requested. */ verdict: string; }; + TrackerIntakeConfig: { + assignee?: string; + enabled?: boolean; + labels?: string[]; + limit?: number; + /** @enum {string} */ + provider?: "github"; + repo?: string; + }; WorkspaceRepo: { name: string; relativePath: string; From eef6e4e22ae4cd7e4ca8f8d1df15dabafd201e83 Mon Sep 17 00:00:00 2001 From: anirudh5harma Date: Sat, 27 Jun 2026 17:45:45 +0530 Subject: [PATCH 2/3] feat(frontend): surface tracker intake controls --- .../components/ProjectSettingsForm.test.tsx | 119 ++++++++++++++++++ .../components/ProjectSettingsForm.tsx | 110 ++++++++++++++++ .../components/SessionInspector.test.tsx | 9 ++ .../renderer/components/SessionInspector.tsx | 1 + .../src/renderer/components/SessionsBoard.tsx | 8 ++ .../renderer/hooks/useWorkspaceQuery.test.tsx | 2 + .../src/renderer/hooks/useWorkspaceQuery.ts | 1 + frontend/src/renderer/types/workspace.ts | 2 + 8 files changed, 252 insertions(+) diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 9ecb1cba..317560ac 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -200,4 +200,123 @@ describe("ProjectSettingsForm", () => { expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2); expect(putMock).not.toHaveBeenCalled(); }); + + it("enables tracker intake and saves a structured rule from the form", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + await userEvent.click(await screen.findByLabelText("Enable issue intake")); + await userEvent.type(screen.getByLabelText("Labels"), "agent-ready, bug"); + await userEvent.type(screen.getByLabelText("Per-poll limit"), "5"); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + const body = putMock.mock.calls[0][1].body.config; + expect(body.trackerIntake).toEqual({ + enabled: true, + provider: "github", + labels: ["agent-ready", "bug"], + limit: 5, + }); + }); + + it("loads an existing tracker intake rule and preserves it on save", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + trackerIntake: { + enabled: true, + provider: "github", + repo: "acme/project-one", + labels: ["agent-ready", "bug"], + assignee: "alice", + limit: 7, + }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + expect(await screen.findByLabelText("Enable issue intake")).toBeChecked(); + expect(screen.getByLabelText("Repository")).toHaveValue("acme/project-one"); + expect(screen.getByLabelText("Labels")).toHaveValue("agent-ready, bug"); + expect(screen.getByLabelText("Assignee")).toHaveValue("alice"); + expect(screen.getByLabelText("Per-poll limit")).toHaveValue(7); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + const body = putMock.mock.calls[0][1].body.config; + expect(body.trackerIntake).toEqual({ + enabled: true, + provider: "github", + repo: "acme/project-one", + labels: ["agent-ready", "bug"], + assignee: "alice", + limit: 7, + }); + }); + + it("blocks saving when intake is enabled without a label or assignee", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + await userEvent.click(await screen.findByLabelText("Enable issue intake")); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect( + await screen.findAllByText("Enabling intake requires at least one label or assignee."), + ).not.toHaveLength(0); + expect(putMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 1c86bb85..156d043b 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". type Project = components["schemas"]["Project"]; type ProjectConfig = components["schemas"]["ProjectConfig"]; +type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; const PERMISSION_MODE_OPTIONS = [ { value: "default", label: "Default" }, @@ -66,6 +67,7 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { const queryClient = useQueryClient(); const config = project.config ?? {}; + const intake = config.trackerIntake ?? {}; const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", sessionPrefix: config.sessionPrefix ?? "", @@ -74,10 +76,36 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje model: config.agentConfig?.model ?? "", permissions: config.agentConfig?.permissions ?? "", reviewerHarness: config.reviewers?.[0]?.harness ?? "", + intakeEnabled: intake.enabled ?? false, + intakeRepo: intake.repo ?? "", + // Labels edit as a comma-separated list so contributors never touch raw JSON. + intakeLabels: (intake.labels ?? []).join(", "), + intakeAssignee: intake.assignee ?? "", + intakeLimit: intake.limit ? String(intake.limit) : "", }); const [savedAt, setSavedAt] = useState(null); const [validationError, setValidationError] = useState(null); const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; + const intakeLabelList = parseLabels(form.intakeLabels); + // Mirror the daemon's guard so enabling intake without a rule is caught inline. + const intakeNeedsRule = form.intakeEnabled && intakeLabelList.length === 0 && form.intakeAssignee.trim() === ""; + + // Build the trackerIntake block from the form, preserving any hidden fields the + // form does not expose. Provider is pinned to github (the only one) when on. + // Omitted entirely when disabled and unconfigured so the config can store NULL. + const buildIntake = (): TrackerIntakeConfig | undefined => { + const limit = Number.parseInt(form.intakeLimit, 10); + const next: TrackerIntakeConfig = { + ...intake, + enabled: form.intakeEnabled || undefined, + provider: form.intakeEnabled ? "github" : undefined, + repo: form.intakeRepo.trim() || undefined, + labels: intakeLabelList.length ? intakeLabelList : undefined, + assignee: form.intakeAssignee.trim() || undefined, + limit: Number.isFinite(limit) && limit > 0 ? limit : undefined, + }; + return blankToUndefined(next); + }; const mutation = useMutation({ mutationFn: async () => { @@ -95,6 +123,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje permissions: form.permissions || undefined, }), reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined, + trackerIntake: buildIntake(), }; const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", { params: { path: { id: projectId } }, @@ -120,6 +149,10 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje setValidationError("Worker and orchestrator agents are required."); return; } + if (intakeNeedsRule) { + setValidationError("Enabling intake requires at least one label or assignee."); + return; + } setValidationError(null); mutation.mutate(); }} @@ -219,6 +252,74 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje + + + Tracker intake + + +

+ Auto-spawn worker sessions from matching tracker issues. Read-only toward the tracker: matching + issues spawn sessions; the tracker is not commented on or transitioned. +

+ + {form.intakeEnabled && ( + <> + + setForm((f) => ({ ...f, intakeRepo: e.target.value }))} + placeholder="owner/repo (defaults to git origin)" + /> + + + setForm((f) => ({ ...f, intakeLabels: e.target.value }))} + placeholder="comma-separated, e.g. agent-ready, bug" + /> + + + setForm((f) => ({ ...f, intakeAssignee: e.target.value }))} + placeholder="github login, or * for any" + /> + + + setForm((f) => ({ ...f, intakeLimit: e.target.value }))} + placeholder="(adapter default)" + /> + + {intakeNeedsRule && ( +

+ Enabling intake requires at least one label or assignee. +

+ )} + + )} +
+
+