# Selective Account Run Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let the user run a migration for only the checkbox-selected accounts; with no selection, run all (unchanged). **Architecture:** A pure `selectAccounts` filter narrows the orchestrator's account slice before the test gate; the run HTTP handler parses an optional `account_ids` body and passes it through. The accounts table gains row checkboxes and a "select all" header, and the single "Run migration" button becomes "Run selected (N)" when a selection exists. Copy is already idempotent (dedup by message key), so a re-run of selected accounts skips copied messages and delivers the missing tail. **Tech Stack:** Go 1.x (net/http, encoding/json, standard testing), React + TypeScript + Vite, existing REST/WS client. ## Global Constraints - Backend module path: `github.com/vasyansk/imap-copier`. - `Run` with an empty/nil account-ID set MUST behave exactly as today (all accounts) — the scheduler depends on it. - Preserve existing `handleRun` error mapping: `ErrNotTested` → 409 "accounts must pass connection tests first"; `ErrAlreadyRunning` → 409 "task is already running". - Frontend has no unit-test harness; frontend tasks verify via `npm run build` + `tsc --noEmit` + manual app check. - Follow existing code style: no new dependencies. --- ### Task 1: Orchestrator account filter + `Run` signature **Files:** - Modify: `internal/orchestrator/orchestrator.go` - Test: `internal/orchestrator/orchestrator_test.go` **Interfaces:** - Produces: - `func selectAccounts(accs []store.Account, ids []int64) []store.Account` — returns `accs` unchanged when `ids` is empty/nil; otherwise returns only the accounts whose `ID` is in `ids`, preserving input order. - `var ErrNoAccountsSelected = errors.New("no matching accounts selected")` - `func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string, accountIDs []int64) (int64, error)` — new trailing `accountIDs` param. - [ ] **Step 1: Write the failing test** Add to `internal/orchestrator/orchestrator_test.go`: ```go 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)) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/orchestrator/ -run TestSelectAccounts -v` Expected: FAIL — `undefined: selectAccounts`. - [ ] **Step 3: Add the filter helper and sentinel error** In `internal/orchestrator/orchestrator.go`, add the sentinel next to the existing `var ErrNotTested`/`ErrAlreadyRunning`: ```go var ErrNoAccountsSelected = errors.New("no matching accounts selected") ``` Add the helper (place it near `gateOK`): ```go // 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 } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/orchestrator/ -run TestSelectAccounts -v` Expected: PASS. - [ ] **Step 5: Thread `accountIDs` through `Run`** In `internal/orchestrator/orchestrator.go`, change the `Run` signature and apply the filter after loading accounts. Replace: ```go func (o *Orchestrator) Run(ctx context.Context, taskID int64, trigger string) (int64, error) { task, err := o.store.GetTask(ctx, taskID) if err != nil { return 0, err } accs, err := o.store.ListAccountsByTask(ctx, taskID) if err != nil { return 0, err } if !gateOK(accs) { return 0, ErrNotTested } ``` with: ```go 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 } accs, err := o.store.ListAccountsByTask(ctx, taskID) 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 } ``` The rest of `Run` (endpoints, `TryMarkTaskRunning`, `CreateRun`, `go o.runAll(...)`) is unchanged — it already operates over the `accs` slice. - [ ] **Step 6: Update the two `Run` call sites so the package compiles** In `internal/scheduler/scheduler.go`, change: ```go if _, err := s.orch.Run(ctx, id, "scheduled"); err != nil { ``` to: ```go if _, err := s.orch.Run(ctx, id, "scheduled", nil); err != nil { ``` In `internal/httpapi/run.go`, inside `handleRun`, change: ```go runID, err := s.orch.Run(r.Context(), taskID, "manual") ``` to (IDs wired in Task 2; for now pass `nil` to keep it compiling): ```go runID, err := s.orch.Run(r.Context(), taskID, "manual", nil) ``` - [ ] **Step 7: Verify the whole backend builds and tests pass** Run: `go build ./... && go test ./internal/orchestrator/ ./internal/scheduler/ -v` Expected: build succeeds; all tests PASS. - [ ] **Step 8: Commit** ```bash git add internal/orchestrator/orchestrator.go internal/orchestrator/orchestrator_test.go internal/scheduler/scheduler.go internal/httpapi/run.go git commit -m "Add selectAccounts filter and accountIDs param to orchestrator.Run" ``` --- ### Task 2: `handleRun` parses optional `account_ids` **Files:** - Modify: `internal/httpapi/run.go` - Test: `internal/httpapi/run_test.go` **Interfaces:** - Consumes: `orchestrator.Run(ctx, taskID, "manual", accountIDs)`, `orchestrator.ErrNoAccountsSelected` (Task 1). - Produces: - `func parseRunAccountIDs(r *http.Request) ([]int64, error)` — returns `nil` for an empty body; otherwise decodes `{"account_ids":[...]}` and returns the slice (possibly empty). Returns an error on malformed JSON. - [ ] **Step 1: Write the failing test** Add to `internal/httpapi/run_test.go` (the `strings` import is already present): ```go func TestParseRunAccountIDs(t *testing.T) { // empty body => nil (run all) req := httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader("")) ids, err := parseRunAccountIDs(req) if err != nil || ids != nil { t.Fatalf("empty body must yield nil ids, got %v err=%v", ids, err) } // explicit selection req = httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(`{"account_ids":[3,7]}`)) ids, err = parseRunAccountIDs(req) if err != nil || len(ids) != 2 || ids[0] != 3 || ids[1] != 7 { t.Fatalf("must parse account_ids, got %v err=%v", ids, err) } // malformed JSON => error req = httptest.NewRequest("POST", "/api/tasks/1/run", strings.NewReader(`{bad`)) if _, err := parseRunAccountIDs(req); err == nil { t.Fatal("malformed body must error") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/httpapi/ -run TestParseRunAccountIDs -v` Expected: FAIL — `undefined: parseRunAccountIDs`. - [ ] **Step 3: Implement `parseRunAccountIDs` and wire it into `handleRun`** In `internal/httpapi/run.go`, add the imports `encoding/json` and `io` to the existing import block, then add the helper: ```go // parseRunAccountIDs reads an optional {"account_ids":[...]} run body. An empty // body means "all accounts" and yields a nil slice. Malformed JSON is an error. func parseRunAccountIDs(r *http.Request) ([]int64, error) { raw, err := io.ReadAll(r.Body) if err != nil { return nil, err } if len(bytes.TrimSpace(raw)) == 0 { return nil, nil } var body struct { AccountIDs []int64 `json:"account_ids"` } if err := json.Unmarshal(raw, &body); err != nil { return nil, err } return body.AccountIDs, nil } ``` Add `"bytes"` to the import block as well (used by `bytes.TrimSpace`). Then replace the body of `handleRun`'s run call. Change: ```go 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 } if errors.Is(err, orchestrator.ErrAlreadyRunning) { http.Error(w, "task is already running", http.StatusConflict) return } ``` to: ```go accountIDs, err := parseRunAccountIDs(r) if err != nil { http.Error(w, "bad request body", http.StatusBadRequest) return } runID, err := s.orch.Run(r.Context(), taskID, "manual", accountIDs) if errors.Is(err, orchestrator.ErrNoAccountsSelected) { http.Error(w, "no matching accounts selected", http.StatusBadRequest) return } if errors.Is(err, orchestrator.ErrNotTested) { http.Error(w, "accounts must pass connection tests first", http.StatusConflict) return } if errors.Is(err, orchestrator.ErrAlreadyRunning) { http.Error(w, "task is already running", http.StatusConflict) return } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/httpapi/ -run TestParseRunAccountIDs -v` Expected: PASS. - [ ] **Step 5: Verify the whole backend builds and the httpapi suite passes** Run: `go build ./... && go test ./internal/httpapi/ -v` Expected: build succeeds; all tests PASS. - [ ] **Step 6: Commit** ```bash git add internal/httpapi/run.go internal/httpapi/run_test.go git commit -m "Parse optional account_ids in run handler" ``` --- ### Task 3: Frontend `runTask` accepts account IDs **Files:** - Modify: `web/src/api.ts:141` **Interfaces:** - Produces: `runTask(id: number, accountIds?: number[]) => Promise<...>` — sends `{account_ids}` JSON only when a non-empty list is given; otherwise a bare POST (all accounts). - [ ] **Step 1: Update `runTask`** In `web/src/api.ts`, replace: ```ts export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' }) ``` with: ```ts export const runTask = (id: number, accountIds?: number[]) => api(`/api/tasks/${id}/run`, accountIds?.length ? jsonBody({ account_ids: accountIds }) : { method: 'POST' }) ``` - [ ] **Step 2: Verify typecheck** Run: `cd web && npx tsc --noEmit` Expected: no errors from `api.ts` (pre-existing warnings elsewhere, if any, are unrelated). - [ ] **Step 3: Commit** ```bash git add web/src/api.ts git commit -m "runTask accepts optional accountIds" ``` --- ### Task 4: Accounts table checkboxes + selective Run button **Files:** - Modify: `web/src/pages/TaskDetail.tsx` - Modify: `web/src/app.css` **Interfaces:** - Consumes: `runTask(id, accountIds?)` (Task 3). - Produces: no exported interface; internal UI state only. - [ ] **Step 1: Add selection state and helpers** In `web/src/pages/TaskDetail.tsx`, add a state hook next to the other `useState` declarations (near line 90): ```tsx const [selected, setSelected] = useState>(new Set()) ``` - [ ] **Step 2: Compute selectable rows and the effective run set** After the existing `const { task, accounts } = data` / `allTested` block (near line 369), add: ```tsx const isRunning = task.status === 'running' // A row is selectable only when both connection tests pass and no run is live. const selectableIds = accounts .filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok') .map((a) => a.id) const selectableSet = new Set(selectableIds) // Effective set: the checked accounts, or all accounts when nothing is checked. const effectiveSelected = accounts.filter((a) => selected.has(a.id)) const runSet = effectiveSelected.length > 0 ? effectiveSelected : accounts const runReady = runSet.length > 0 && runSet.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok') const allSelectableChecked = selectableIds.length > 0 && selectableIds.every((id) => selected.has(id)) const someSelectableChecked = selectableIds.some((id) => selected.has(id)) function toggleOne(accId: number, checked: boolean) { setSelected((prev) => { const next = new Set(prev) if (checked) next.add(accId) else next.delete(accId) return next }) } function toggleAll(checked: boolean) { setSelected(checked ? new Set(selectableIds) : new Set()) } ``` - [ ] **Step 3: Make `onRun` send the selection** Replace the existing `onRun` function (near line 331): ```tsx async function onRun() { setBusy('run') setError(null) try { await runTask(id) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start run') } finally { setBusy(null) } } ``` with: ```tsx async function onRun() { setBusy('run') setError(null) try { const ids = accounts.filter((a) => selected.has(a.id)).map((a) => a.id) await runTask(id, ids.length ? ids : undefined) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start run') } finally { setBusy(null) } } ``` - [ ] **Step 4: Update the Run button label and gate** Replace the run button (near line 421): ```tsx {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides} ``` with: ```tsx {!runReady && accounts.length > 0 && ( {effectiveSelected.length > 0 ? 'selected accounts must pass both connection tests' : 'run unlocks once every account tests OK on both sides'} )} ``` - [ ] **Step 5: Add the checkbox header column** In the accounts table `` (near line 542), add a leading `` before `Account`: ```tsx { if (el) el.indeterminate = !allSelectableChecked && someSelectableChecked }} disabled={isRunning || selectableIds.length === 0} onChange={(e) => toggleAll(e.target.checked)} /> ``` - [ ] **Step 6: Add the per-row checkbox cell and fix the empty-row colspan** In the empty-row branch (near line 557), change `colSpan={9}` to `colSpan={10}`. In the account `` map (near line 561), add a leading `` as the first child, before the `` with `acct-ident`: ```tsx toggleOne(a.id, e.target.checked)} /> ``` - [ ] **Step 7: Add checkbox column CSS** In `web/src/app.css`, append: ```css .tbl .chk-col { width: 32px; text-align: center; padding-right: 0; } .tbl .chk-col input[type='checkbox'] { cursor: pointer; } .tbl .chk-col input[type='checkbox']:disabled { cursor: not-allowed; opacity: 0.4; } ``` - [ ] **Step 8: Build and typecheck** Run: `cd web && npx tsc --noEmit && npm run build` Expected: typecheck clean (any pre-existing oxlint/fast-refresh warnings are unrelated); Vite build succeeds. - [ ] **Step 9: Manual verification in the running app** Start the app, open a task with ≥2 accounts that have passed both tests: 1. With nothing checked, the button reads "Run migration" and runs all accounts (confirm `account_started` WS events for every account). 2. Check a subset; the button reads "Run selected (N)". Run it; confirm only the checked accounts start, and the unchecked accounts' Copied/Skipped/Status are untouched. 3. Confirm checkboxes for untested accounts are disabled, and the header checkbox toggles only the selectable rows. - [ ] **Step 10: Commit** ```bash git add web/src/pages/TaskDetail.tsx web/src/app.css git commit -m "Add account selection checkboxes and selective run button" ``` --- ## Notes for the executor - Tasks 1 and 2 are backend and must land in order (Task 2 depends on the new `Run` signature and `ErrNoAccountsSelected`). Task 3 and 4 are frontend; Task 4 depends on Task 3. - The orchestrator's `Run` is integration-heavy (spawns goroutines, needs a real store/hub), so it is not unit-tested directly; the pure `selectAccounts` filter and the `parseRunAccountIDs` helper carry the backend test coverage, and Task 4 Step 9 covers the end-to-end behavior manually.