diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go index 2195673..b4f79af 100644 --- a/internal/httpapi/run.go +++ b/internal/httpapi/run.go @@ -69,7 +69,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad id", http.StatusBadRequest) return } - runID, err := s.orch.Run(r.Context(), taskID, "manual") + runID, err := s.orch.Run(r.Context(), taskID, "manual", nil) if errors.Is(err, orchestrator.ErrNotTested) { http.Error(w, "accounts must pass connection tests first", http.StatusConflict) return diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index eb461cb..96cfff1 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -16,6 +16,7 @@ import ( var ErrNotTested = errors.New("accounts not fully tested") var ErrAlreadyRunning = errors.New("task already running") +var ErrNoAccountsSelected = errors.New("no matching accounts selected") // maxAccountErrors caps how many individual error rows one account records per // run, so a corrupt mailbox producing thousands of failures can't bloat the @@ -99,6 +100,26 @@ func (o *Orchestrator) unregisterCancel(accountID int64) { o.mu.Unlock() } +// selectAccounts narrows accs to those whose ID is in ids, preserving input +// order. An empty or nil ids means "all accounts" — the scheduler and the +// unfiltered manual run rely on this. +func selectAccounts(accs []store.Account, ids []int64) []store.Account { + if len(ids) == 0 { + return accs + } + want := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + want[id] = struct{}{} + } + out := make([]store.Account, 0, len(ids)) + for _, a := range accs { + if _, ok := want[a.ID]; ok { + out = append(out, a) + } + } + return out +} + func gateOK(accs []store.Account) bool { if len(accs) == 0 { return false @@ -173,7 +194,7 @@ func shouldBreak(trigger string, totErr int64) bool { return trigger == "scheduled" && totErr > 0 } -func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string) (int64, error) { +func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string, accountIDs []int64) (int64, error) { task, err := o.store.GetTask(ctx, taskID) if err != nil { return 0, err @@ -182,6 +203,12 @@ func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string) (i if err != nil { return 0, err } + accs = selectAccounts(accs, accountIDs) + // A non-empty request that matched nothing is a client error, distinct + // from "not tested". + if len(accountIDs) > 0 && len(accs) == 0 { + return 0, ErrNoAccountsSelected + } if !gateOK(accs) { return 0, ErrNotTested } diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index d83ea6c..2dc363c 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -22,3 +22,23 @@ func TestGateOK(t *testing.T) { t.Fatal("empty accounts must fail gate") } } + +func TestSelectAccounts(t *testing.T) { + accs := []store.Account{{ID: 1}, {ID: 2}, {ID: 3}} + + if got := selectAccounts(accs, nil); len(got) != 3 { + t.Fatalf("nil ids must return all, got %d", len(got)) + } + if got := selectAccounts(accs, []int64{}); len(got) != 3 { + t.Fatalf("empty ids must return all, got %d", len(got)) + } + + got := selectAccounts(accs, []int64{3, 1}) + if len(got) != 2 || got[0].ID != 1 || got[1].ID != 3 { + t.Fatalf("must keep matching ids in input order, got %+v", got) + } + + if got := selectAccounts(accs, []int64{99}); len(got) != 0 { + t.Fatalf("unknown ids must yield empty, got %d", len(got)) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 6844ced..2d6ae27 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -67,7 +67,7 @@ func (s *Scheduler) tick(ctx context.Context) { return } for _, id := range dueTaskIDs(tasks, time.Now()) { - if _, err := s.orch.Run(ctx, id, "scheduled"); err != nil { + if _, err := s.orch.Run(ctx, id, "scheduled", nil); err != nil { // ErrAlreadyRunning / ErrNotTested are expected races/edge cases, not fatal. slog.Info("scheduler: run skipped", "task", id, "err", err) }